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
@@ -0,0 +1,529 @@
1
+ /**
2
+ * Autodesk Fusion through faust-machines/fusion360-mcp-server (PRD §8.9).
3
+ * An MCP client talks stdio to that server, which talks TCP (localhost:9876)
4
+ * to the Fusion360MCP add-in inside Fusion. Mirrors blender-mcp-bridge.ts:
5
+ * executables come only from server configuration, only FUSION_* settings
6
+ * reach the child process, argument schemas are discovered and enforced, and a
7
+ * request that may have reached Fusion is never replayed. Unlike the Blender
8
+ * bridge, the client stays connected for the life of this server, so a design
9
+ * pass's many calls share one Fusion connection.
10
+ */
11
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
12
+ import { getDefaultEnvironment } from "@modelcontextprotocol/sdk/client/stdio.js";
13
+ import { AjvJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/ajv";
14
+ import { ErrorCode } from "@modelcontextprotocol/sdk/types.js";
15
+ import { execFile } from "node:child_process";
16
+ import { randomUUID } from "node:crypto";
17
+ import fs from "node:fs/promises";
18
+ import net from "node:net";
19
+ import path from "node:path";
20
+ import { promisify } from "node:util";
21
+ import { ClosingStdioTransport, editDeadline, regularStl } from "./blender-mcp-bridge.js";
22
+ const execFileAsync = promisify(execFile);
23
+ /** The pinned faust-machines commit (PRD §8.9). The PyPI 0.1.0 release crashes under mcp 2.x (issue #30). */
24
+ export const FUSION_MCP_PINNED_COMMIT = "19b9f1e95192";
25
+ export const FUSION_MCP_DEFAULT_ARGS = [
26
+ "--from", `git+https://github.com/faust-machines/fusion360-mcp-server@${FUSION_MCP_PINNED_COMMIT}`,
27
+ "fusion360-mcp-server", "--mode", "socket",
28
+ ];
29
+ /** PRD §8.9: Blender's MCP addon holds the add-in's own default, 9876, so Fusion uses 9877. */
30
+ export const FUSION_ADDIN_DEFAULT_PORT = 9877;
31
+ const DEFAULT_TIMEOUT_MS = 180000;
32
+ const LAUNCH_WAIT_MS = 90000;
33
+ const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
34
+ class FusionError extends Error {
35
+ }
36
+ function allowedArguments(args, names) {
37
+ if (Object.keys(args).some((name) => !names.includes(name))) {
38
+ throw new FusionError("Unknown Fusion argument. Executables and their arguments must be configured on the server with FUSION_MCP_COMMAND and FUSION_MCP_ARGS.");
39
+ }
40
+ }
41
+ function textArgument(value, label) {
42
+ if (typeof value !== "string" || !value.trim() || value.includes("\0"))
43
+ throw new FusionError(`${label} must be a non-empty string without NUL characters.`);
44
+ return value;
45
+ }
46
+ function booleanArgument(value, label) {
47
+ if (value === undefined)
48
+ return false;
49
+ if (typeof value !== "boolean")
50
+ throw new FusionError(`${label} must be a boolean.`);
51
+ return value;
52
+ }
53
+ function timeoutMilliseconds(value) {
54
+ const timeout = value ?? (process.env.FUSION_MCP_TIMEOUT_MS ? Number(process.env.FUSION_MCP_TIMEOUT_MS) : DEFAULT_TIMEOUT_MS);
55
+ if (typeof timeout !== "number" || !Number.isInteger(timeout) || timeout < 100 || timeout > 600000) {
56
+ throw new FusionError("timeout_ms / FUSION_MCP_TIMEOUT_MS must be an integer from 100 to 600000.");
57
+ }
58
+ return timeout;
59
+ }
60
+ function configuration() {
61
+ const command = process.env.FUSION_MCP_COMMAND?.trim();
62
+ if (!command) {
63
+ throw new FusionError("No Fusion MCP server configured. Set FUSION_MCP_COMMAND to the full path of uvx; FUSION_MCP_ARGS defaults to the pinned " +
64
+ `faust-machines/fusion360-mcp-server@${FUSION_MCP_PINNED_COMMIT} in socket mode. Install the Fusion360MCP add-in from the same commit and set it to Run on Startup.`);
65
+ }
66
+ let args = FUSION_MCP_DEFAULT_ARGS;
67
+ const raw = process.env.FUSION_MCP_ARGS?.trim();
68
+ if (raw) {
69
+ try {
70
+ args = JSON.parse(raw);
71
+ }
72
+ catch {
73
+ throw new FusionError("FUSION_MCP_ARGS must be a JSON array of strings, not a shell command.");
74
+ }
75
+ }
76
+ if (!Array.isArray(args) || args.some((arg) => typeof arg !== "string" || arg.includes("\0"))) {
77
+ throw new FusionError("FUSION_MCP_ARGS must be a JSON array of strings without NUL characters.");
78
+ }
79
+ if (command.includes("\0"))
80
+ throw new FusionError("FUSION_MCP_COMMAND must be an executable without NUL characters.");
81
+ return { command, args };
82
+ }
83
+ function fusionEnvironment() {
84
+ // The Fusion server needs its add-in address, not printer access codes or other server secrets.
85
+ const env = getDefaultEnvironment();
86
+ for (const [key, value] of Object.entries(process.env)) {
87
+ if (key.startsWith("FUSION_") && value !== undefined && !["FUSION_MCP_COMMAND", "FUSION_MCP_ARGS"].includes(key))
88
+ env[key] = value;
89
+ }
90
+ // The faust server reads FUSION_MCP_PORT too (src/fusion360_mcp/connection.py), so it dials the port this bridge probes.
91
+ env.FUSION_MCP_PORT = String(addinAddress().port);
92
+ return env;
93
+ }
94
+ function addinAddress() {
95
+ const host = process.env.FUSION_MCP_HOST?.trim() || "localhost";
96
+ const port = Number(process.env.FUSION_MCP_PORT?.trim() || FUSION_ADDIN_DEFAULT_PORT);
97
+ return { host, port: Number.isInteger(port) && port > 0 && port < 65536 ? port : FUSION_ADDIN_DEFAULT_PORT };
98
+ }
99
+ /** faust reports failures as text ("**tool** ERROR (KIND)") with isError set; treat either as an error. */
100
+ function normalizeToolFailure(result) {
101
+ if (result.isError)
102
+ return result;
103
+ const failed = result.content.some((entry) => entry.type === "text" && /^\s*\*\*[a-z0-9_]+\*\* ERROR\b|^\s*Error \([a-z0-9_]+\):/i.test(entry.text));
104
+ return failed ? { ...result, isError: true } : result;
105
+ }
106
+ function resultText(result) {
107
+ return result.content.filter((entry) => entry.type === "text").map((entry) => entry.text).join("\n");
108
+ }
109
+ /** execute_code returns its last expression as a " result: <value>" line; our code returns one JSON line. */
110
+ function executeCodeJson(result, marker) {
111
+ const line = resultText(result).split("\n").find((entry) => entry.trimStart().startsWith("result: {"));
112
+ let value;
113
+ try {
114
+ value = JSON.parse(line?.trimStart().slice("result: ".length) ?? "null");
115
+ }
116
+ catch {
117
+ value = undefined;
118
+ }
119
+ if (!value || typeof value !== "object" || value[marker] !== true) {
120
+ throw new FusionError("Fusion did not return the expected execute_code result; nothing was published. Check the Fusion360MCP add-in and its faust-machines commit.");
121
+ }
122
+ return value;
123
+ }
124
+ function noActiveDesign(result) {
125
+ return /NO_ACTIVE_DESIGN|No active design/i.test(resultText(result));
126
+ }
127
+ const NO_ACTIVE_DESIGN_HELP = "Fusion has no open design, and at the pinned faust-machines commit no tool can create the first one: every tool, including execute_code, " +
128
+ "needs an active design (faust-machines/fusion360-mcp-server issue #12). Open or create any design in Fusion once (File > New Design), then try again.";
129
+ /**
130
+ * True only when the Fusion360MCP add-in answers its ping. A bare TCP connect
131
+ * is not enough: Blender's MCP addon listens on the same default port (9876)
132
+ * and answers "unknown command" to this harmless request.
133
+ * Protocol: newline-delimited JSON, {"type","params"} in and
134
+ * {"status":"success","result":{"pong":true}} out (add-in server/socket_server.py).
135
+ */
136
+ function addinAnswers(host, port, timeoutMs) {
137
+ return new Promise((resolve) => {
138
+ const socket = net.connect({ host, port });
139
+ let buffer = "";
140
+ let settled = false;
141
+ const done = (value) => { if (settled)
142
+ return; settled = true; clearTimeout(deadline); socket.destroy(); resolve(value); };
143
+ // A hard total deadline: socket.setTimeout is an idle timer that a listener sending a trickle of bytes could hold open.
144
+ const deadline = setTimeout(() => done(false), timeoutMs);
145
+ socket.once("connect", () => socket.write(`${JSON.stringify({ type: "ping", params: {} })}\n`));
146
+ socket.on("data", (chunk) => {
147
+ buffer += chunk.toString("utf8");
148
+ if (buffer.length > 65536)
149
+ return done(false);
150
+ const line = buffer.includes("\n") ? buffer.slice(0, buffer.indexOf("\n")) : buffer;
151
+ try {
152
+ const reply = JSON.parse(line);
153
+ done(reply?.status === "success" && reply?.result?.pong === true);
154
+ }
155
+ catch {
156
+ if (buffer.includes("\n"))
157
+ done(false);
158
+ }
159
+ });
160
+ socket.once("error", () => done(false));
161
+ socket.once("close", () => done(false));
162
+ });
163
+ }
164
+ /** The Fusion app process on macOS ("Autodesk Fusion", formerly "Autodesk Fusion 360"); undefined elsewhere. */
165
+ async function fusionProcessRunning() {
166
+ if (process.platform !== "darwin")
167
+ return undefined;
168
+ try {
169
+ await execFileAsync("pgrep", ["-f", "/Contents/MacOS/Autodesk Fusion"], { timeout: 5000 });
170
+ return true;
171
+ }
172
+ catch {
173
+ return false;
174
+ }
175
+ }
176
+ /** `open --env` applies only when it starts the app, which is the only time this runs. */
177
+ export function fusionLaunchArguments(port) {
178
+ return ["-a", "Autodesk Fusion", "--env", `FUSION_MCP_PORT=${port}`];
179
+ }
180
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
181
+ export class FusionMcpBridge {
182
+ /** One client for the life of this server; reconnects on the next call after the transport closes. */
183
+ async connect(timeout, signal) {
184
+ if (this.session)
185
+ return this.session;
186
+ this.connecting ?? (this.connecting = this.open(timeout, signal).finally(() => { this.connecting = undefined; }));
187
+ return this.connecting;
188
+ }
189
+ async open(timeout, signal) {
190
+ const config = configuration();
191
+ const client = new Client({ name: "mcp-3d-printer-server-fusion", version: "1.0.0" });
192
+ const transport = new ClosingStdioTransport({ ...config, env: fusionEnvironment(), stderr: "pipe" });
193
+ // Drain stderr without relaying arbitrary logs into the outer MCP stream.
194
+ transport.stderr?.on("data", () => { });
195
+ const deadline = Date.now() + timeout;
196
+ const options = () => ({ signal, timeout: Math.max(1, deadline - Date.now()) });
197
+ let phase = "initialization";
198
+ try {
199
+ await client.connect(transport, options());
200
+ phase = "tool discovery";
201
+ const tools = [];
202
+ const cursors = new Set();
203
+ let cursor;
204
+ do {
205
+ const page = await client.listTools(cursor ? { cursor } : {}, options());
206
+ tools.push(...page.tools);
207
+ cursor = page.nextCursor;
208
+ if (cursor && (cursors.has(cursor) || cursors.size >= 99))
209
+ throw new FusionError("Fusion MCP returned an invalid or excessive tools/list pagination sequence.");
210
+ if (cursor)
211
+ cursors.add(cursor);
212
+ } while (cursor);
213
+ const session = { client, transport, tools };
214
+ client.onclose = () => { if (this.session === session)
215
+ this.session = undefined; };
216
+ this.session = session;
217
+ return session;
218
+ }
219
+ catch (error) {
220
+ await client.close().catch(() => { });
221
+ await transport.close().catch(() => { });
222
+ if (error instanceof FusionError)
223
+ throw error;
224
+ const code = error?.code;
225
+ if (code === ErrorCode.RequestTimeout || Date.now() >= deadline) {
226
+ throw new FusionError(`Fusion MCP timed out during ${phase}. The first start downloads the pinned server with uvx; check FUSION_MCP_COMMAND and FUSION_MCP_ARGS, or raise FUSION_MCP_TIMEOUT_MS.`);
227
+ }
228
+ if (signal?.aborted)
229
+ throw new FusionError("Fusion MCP request cancelled.");
230
+ const safeCode = typeof code === "number" ? ` (RPC ${code})` : ["ENOENT", "EACCES"].includes(String(code)) ? ` (${code})` : "";
231
+ throw new FusionError(`Fusion MCP failed during ${phase}${safeCode}. Check FUSION_MCP_COMMAND and FUSION_MCP_ARGS.`);
232
+ }
233
+ }
234
+ /** Call a discovered tool once; a failure after dispatch is reported, never replayed. */
235
+ async invoke(name, args, timeout, signal) {
236
+ const session = await this.connect(timeout, signal);
237
+ const tool = session.tools.find((entry) => entry.name === name);
238
+ if (!tool)
239
+ throw new FusionError("The requested tool is not advertised by Fusion MCP. Call fusion_mcp_status to list the available tools.");
240
+ let valid;
241
+ try {
242
+ valid = new AjvJsonSchemaValidator().getValidator(tool.inputSchema)(args).valid;
243
+ }
244
+ catch {
245
+ throw new FusionError("Fusion MCP advertised an unsupported input schema for this tool.");
246
+ }
247
+ if (!valid)
248
+ throw new FusionError("Fusion tool arguments do not match its advertised input schema. Call fusion_mcp_status with tool_names: [\"<tool>\"] to read the schema.");
249
+ try {
250
+ return normalizeToolFailure(await session.client.callTool({ name, arguments: args }, undefined, { signal, timeout }));
251
+ }
252
+ catch (error) {
253
+ const code = error?.code;
254
+ if (code === ErrorCode.ConnectionClosed)
255
+ this.session = undefined;
256
+ const reason = code === ErrorCode.RequestTimeout ? "timed out" : signal?.aborted ? "was cancelled" : "failed";
257
+ throw new FusionError(`Fusion MCP request ${reason}. It may already have reached Fusion; inspect the design before retrying. The request was not replayed.`);
258
+ }
259
+ }
260
+ async close() {
261
+ const session = this.session;
262
+ this.session = undefined;
263
+ if (!session)
264
+ return;
265
+ await session.client.close().catch(() => { });
266
+ await session.transport.close().catch(() => { });
267
+ }
268
+ /**
269
+ * {configured, connected, addinReachable, fusionRunning, tools[]}. connected
270
+ * means the server started and the add-in answered its ping. With launch:
271
+ * true and Fusion not running on this Mac, opens Fusion and waits up to 90 s
272
+ * for the add-in port.
273
+ */
274
+ async status(args, signal) {
275
+ allowedArguments(args, ["launch", "timeout_ms"]);
276
+ const launch = booleanArgument(args.launch, "launch");
277
+ const timeout = timeoutMilliseconds(args.timeout_ms);
278
+ const configured = Boolean(process.env.FUSION_MCP_COMMAND?.trim());
279
+ const { host, port } = addinAddress();
280
+ const local = LOCAL_HOSTS.has(host.toLowerCase());
281
+ let addinReachable = await addinAnswers(host, port, 2000);
282
+ let processRunning = local ? await fusionProcessRunning() : undefined;
283
+ let launched = false;
284
+ if (launch && !addinReachable && processRunning !== true) {
285
+ if (process.platform !== "darwin" || !local)
286
+ throw new FusionError("launch is supported only for Fusion on this Mac; start Fusion yourself.");
287
+ try {
288
+ // A GUI app gets no shell environment; the add-in reads FUSION_MCP_PORT from Fusion's (addon/Fusion360MCP.py).
289
+ await execFileAsync("open", fusionLaunchArguments(port), { timeout: 30000 });
290
+ launched = true;
291
+ }
292
+ catch {
293
+ throw new FusionError(`Could not start Autodesk Fusion with open -a "Autodesk Fusion" --env FUSION_MCP_PORT=${port}. Is it installed?`);
294
+ }
295
+ }
296
+ if (launch && !addinReachable) {
297
+ const until = Date.now() + Math.min(LAUNCH_WAIT_MS, timeout);
298
+ while (!addinReachable && Date.now() < until) {
299
+ if (signal?.aborted)
300
+ throw new FusionError("Fusion MCP request cancelled while waiting for Fusion to start.");
301
+ await sleep(1000);
302
+ addinReachable = await addinAnswers(host, port, 2000);
303
+ }
304
+ if (local)
305
+ processRunning = await fusionProcessRunning();
306
+ }
307
+ // Off macOS, or with Fusion on another computer, the add-in answering is the evidence that Fusion runs.
308
+ const fusionRunning = processRunning === true || addinReachable;
309
+ const result = { configured, connected: false, addinReachable, fusionRunning, tools: [], launched, transport: "stdio", pin: FUSION_MCP_PINNED_COMMIT };
310
+ if (!configured)
311
+ return result;
312
+ try {
313
+ const session = await this.connect(timeout, signal);
314
+ result.tools = session.tools;
315
+ result.server = session.client.getServerVersion();
316
+ // faust's ping answers through the add-in without touching Fusion's API.
317
+ if (addinReachable && session.tools.some((tool) => tool.name === "ping")) {
318
+ result.connected = !(await this.invoke("ping", {}, Math.min(timeout, 15000), signal)).isError;
319
+ }
320
+ }
321
+ catch (error) {
322
+ result.error = error instanceof Error ? error.message : String(error);
323
+ }
324
+ return result;
325
+ }
326
+ /** Pass a call through to a discovered faust tool with its full content and errors (the blender_mcp_call contract). */
327
+ async call(args, signal) {
328
+ allowedArguments(args, ["tool_name", "arguments", "timeout_ms"]);
329
+ const name = textArgument(args.tool_name, "tool_name");
330
+ const toolArgs = args.arguments ?? {};
331
+ if (!toolArgs || typeof toolArgs !== "object" || Array.isArray(toolArgs))
332
+ throw new FusionError("arguments must be an object.");
333
+ const result = await this.invoke(name, toolArgs, timeoutMilliseconds(args.timeout_ms), signal);
334
+ // The remote result passes through unchanged; its structuredContent satisfies the open schema.
335
+ return { ...result, structuredContent: result.structuredContent ?? {} };
336
+ }
337
+ /** A new parametric design document, so faust tools have an active design. */
338
+ async newDesign(args, signal) {
339
+ allowedArguments(args, ["timeout_ms"]);
340
+ const timeout = timeoutMilliseconds(args.timeout_ms);
341
+ const code = [
342
+ "import json",
343
+ "doc = app.documents.add(adsk.core.DocumentTypes.FusionDesignDocumentType)",
344
+ "new_design = adsk.fusion.Design.cast(doc.products.itemByProductType('DesignProductType'))",
345
+ "new_design.designType = adsk.fusion.DesignTypes.ParametricDesignType",
346
+ "json.dumps({'mcp3d_new_design': True, 'document': doc.name, 'parametric': new_design.designType == adsk.fusion.DesignTypes.ParametricDesignType})",
347
+ ].join("\n");
348
+ const result = await this.invoke("execute_code", { code }, timeout, signal);
349
+ if (result.isError) {
350
+ if (noActiveDesign(result))
351
+ throw new FusionError(NO_ACTIVE_DESIGN_HELP);
352
+ throw new FusionError(`Fusion could not create a design: ${resultText(result).slice(0, 600) || "no details"}`);
353
+ }
354
+ const created = executeCodeJson(result, "mcp3d_new_design");
355
+ if (typeof created.document !== "string")
356
+ throw new FusionError("Fusion did not report the new design document.");
357
+ return { status: "created", document: created.document, parametric: created.parametric === true };
358
+ }
359
+ /**
360
+ * Write <name>.stl (export_stl), <name>.f3d (export_f3d) and
361
+ * <name>.parameters.json into output_dir. Files are staged, verified and
362
+ * then published without ever replacing an existing file.
363
+ */
364
+ async export(args, signal) {
365
+ allowedArguments(args, ["output_dir", "name", "body_name", "timeout_ms"]);
366
+ const timeout = timeoutMilliseconds(args.timeout_ms);
367
+ const deadline = editDeadline(timeout, signal);
368
+ const name = textArgument(args.name, "name");
369
+ if (!/^[A-Za-z0-9][A-Za-z0-9 ._-]{0,99}$/.test(name) || name.includes("..")) {
370
+ throw new FusionError("name must be a plain file name of letters, digits, spaces, dots, hyphens or underscores, without a path.");
371
+ }
372
+ const requestedDir = textArgument(args.output_dir, "output_dir");
373
+ if (!path.isAbsolute(requestedDir))
374
+ throw new FusionError("output_dir must be an absolute path.");
375
+ const outputDir = await fs.realpath(requestedDir).catch(() => { throw new FusionError("output_dir must be an existing directory."); });
376
+ if (!(await fs.stat(outputDir)).isDirectory())
377
+ throw new FusionError("output_dir must be an existing directory.");
378
+ const targets = { stl: path.join(outputDir, `${name}.stl`), f3d: path.join(outputDir, `${name}.f3d`), parameters: path.join(outputDir, `${name}.parameters.json`) };
379
+ for (const target of Object.values(targets)) {
380
+ if (await fs.lstat(target).then(() => true, () => false))
381
+ throw new FusionError(`${target} already exists; fusion_mcp_export never overwrites files.`);
382
+ }
383
+ if (args.body_name !== undefined)
384
+ textArgument(args.body_name, "body_name");
385
+ // The bodies export_stl can resolve by name, in its own order (_body_by_name, addon/server/command_handler.py
386
+ // at the pin): root bodies, then each occurrence's proxies, hidden or not. Sizes are in Fusion's centimetres,
387
+ // in root space like the exported mesh. `component` is the root component in execute_code.
388
+ const probe = await this.invoke("execute_code", { code: [
389
+ "import json",
390
+ "bodies = []",
391
+ "for b in list(component.bRepBodies) + [p for o in component.allOccurrences for p in o.bRepBodies]:",
392
+ " bb = b.boundingBox",
393
+ " bodies.append({'name': b.name, 'solid': b.isSolid, 'visible': b.isVisible, 'size_cm': [bb.maxPoint.x - bb.minPoint.x, bb.maxPoint.y - bb.minPoint.y, bb.maxPoint.z - bb.minPoint.z]})",
394
+ "json.dumps({'mcp3d_bodies': True, 'bodies': bodies})",
395
+ ].join("\n") }, deadline.remaining(), signal);
396
+ if (probe.isError) {
397
+ if (noActiveDesign(probe))
398
+ throw new FusionError(NO_ACTIVE_DESIGN_HELP);
399
+ throw new FusionError(`Fusion could not list the design's bodies: ${resultText(probe).slice(0, 600) || "no details"}`);
400
+ }
401
+ const everyBody = executeCodeJson(probe, "mcp3d_bodies").bodies ?? [];
402
+ const bodies = everyBody.filter((entry) => entry.solid === true && entry.visible === true);
403
+ const bodyName = args.body_name;
404
+ const body = bodyName ? bodies.find((entry) => entry.name === bodyName) : bodies.length === 1 ? bodies[0] : undefined;
405
+ if (!body) {
406
+ if (bodyName)
407
+ throw new FusionError(`No visible solid body named "${bodyName}". Visible bodies: ${bodies.map((entry) => entry.name).join(", ") || "none"}.`);
408
+ throw new FusionError(bodies.length
409
+ ? `The design has ${bodies.length} visible solid bodies (${bodies.map((entry) => entry.name).join(", ")}); pass body_name.`
410
+ : "The design has no visible solid body to export.");
411
+ }
412
+ // export_stl takes the first body with this name, hidden or not, so the name must be unique among all of them.
413
+ // This also refuses copies of an instanced component, which share their bodies' names.
414
+ if (everyBody.filter((entry) => entry.name === body.name).length > 1) {
415
+ throw new FusionError(`Several bodies are named "${body.name}" (including hidden ones and component copies); rename them in Fusion so the export is unambiguous.`);
416
+ }
417
+ const id = randomUUID();
418
+ const staged = { stl: path.join(outputDir, `.mcp3d-${id}.stl`), f3d: path.join(outputDir, `.mcp3d-${id}.f3d`) };
419
+ const published = [];
420
+ try {
421
+ const stlResult = await this.invoke("export_stl", { body_name: body.name, file_path: staged.stl }, deadline.remaining(), signal);
422
+ if (stlResult.isError)
423
+ throw new FusionError(`Fusion could not export the STL: ${resultText(stlResult).slice(0, 600) || "no details"}. Nothing was published.`);
424
+ const f3dResult = await this.invoke("export_f3d", { file_path: staged.f3d }, deadline.remaining(), signal);
425
+ if (f3dResult.isError)
426
+ throw new FusionError(`Fusion could not export the F3D: ${resultText(f3dResult).slice(0, 600) || "no details"}. Nothing was published.`);
427
+ // get_parameters returns a Python repr in text; the same fields come back as JSON through execute_code.
428
+ const parametersResult = await this.invoke("execute_code", { code: [
429
+ "import json",
430
+ "params = [{'name': p.name, 'value': p.value, 'expression': p.expression, 'unit': p.unit, 'comment': p.comment} for p in design.userParameters]",
431
+ "json.dumps({'mcp3d_parameters': True, 'parameters': params, 'count': len(params)})",
432
+ ].join("\n") }, deadline.remaining(), signal);
433
+ if (parametersResult.isError)
434
+ throw new FusionError(`Fusion could not read the design's parameters: ${resultText(parametersResult).slice(0, 600) || "no details"}. Nothing was published.`);
435
+ const parameters = executeCodeJson(parametersResult, "mcp3d_parameters");
436
+ const missing = async (file) => !(await fs.lstat(file).then((stat) => stat.isFile() && stat.size > 0, () => false));
437
+ if (await missing(staged.stl) || await missing(staged.f3d)) {
438
+ throw new FusionError("Fusion reported an export but the files are not in output_dir. fusion_mcp_export needs Fusion and this server on the same computer (FUSION_MCP_HOST local).");
439
+ }
440
+ const metadata = await regularStl(staged.stl, deadline);
441
+ // Fusion measures in centimetres; the STL's own numbers show which unit it was written in.
442
+ const largestCm = Math.max(...body.size_cm);
443
+ const largestStl = Math.max(...metadata.bounding_box.dimensions);
444
+ const ratio = largestCm > 0 ? largestStl / largestCm : NaN;
445
+ const stlUnits = Math.abs(ratio - 10) < 0.5 ? "mm" : Math.abs(ratio - 1) < 0.05 ? "cm" : "unknown";
446
+ const warnings = [];
447
+ if (stlUnits === "cm")
448
+ warnings.push("The STL is in centimetres; slicers read STL numbers as millimetres. Scale it by 10 before slicing.");
449
+ if (stlUnits === "unknown")
450
+ warnings.push("The STL's size does not match the Fusion body's size in mm or cm; check it before slicing.");
451
+ deadline.check();
452
+ await fs.link(staged.stl, targets.stl).catch(() => { throw new FusionError(`Cannot publish ${targets.stl}; it may already exist.`); });
453
+ published.push(targets.stl);
454
+ await fs.link(staged.f3d, targets.f3d).catch(() => { throw new FusionError(`Cannot publish ${targets.f3d}; it may already exist.`); });
455
+ published.push(targets.f3d);
456
+ await fs.writeFile(targets.parameters, `${JSON.stringify({ parameters: parameters.parameters ?? [], count: parameters.count ?? 0 }, null, 2)}\n`, { flag: "wx" })
457
+ .catch(() => { throw new FusionError(`Cannot publish ${targets.parameters}; it may already exist.`); });
458
+ published.push(targets.parameters);
459
+ return {
460
+ status: "exported",
461
+ ...targets,
462
+ body: body.name,
463
+ triangles: metadata.triangles,
464
+ bytes: metadata.bytes,
465
+ parameterCount: Array.isArray(parameters.parameters) ? parameters.parameters.length : 0,
466
+ boundingBox: metadata.bounding_box,
467
+ stlUnits,
468
+ fusionSizeMm: body.size_cm.map((value) => Math.round(value * 10 * 1e4) / 1e4),
469
+ ...(warnings.length ? { warnings } : {}),
470
+ };
471
+ }
472
+ catch (error) {
473
+ // Remove only what this call published; nothing else in output_dir is touched.
474
+ for (const file of published)
475
+ await fs.rm(file, { force: true }).catch(() => { });
476
+ throw error;
477
+ }
478
+ finally {
479
+ await fs.rm(staged.stl, { force: true }).catch(() => { });
480
+ await fs.rm(staged.f3d, { force: true }).catch(() => { });
481
+ }
482
+ }
483
+ }
484
+ // ---- outputSchemas: the same shapes as bambu-printer-mcp's Fusion bridge ----
485
+ export const FUSION_STATUS_OUTPUT_SCHEMA = {
486
+ type: "object",
487
+ properties: {
488
+ configured: { type: "boolean", description: "FUSION_MCP_COMMAND is set." },
489
+ connected: { type: "boolean", description: "The Fusion MCP server started and the add-in answered its ping." },
490
+ addinReachable: { type: "boolean", description: "The Fusion360MCP add-in answered a ping on FUSION_MCP_HOST:FUSION_MCP_PORT (default localhost:9877) within 2 s. Another program on that port, such as Blender's MCP addon, does not count." },
491
+ fusionRunning: { type: "boolean" },
492
+ tools: { type: "array", items: { type: "object", properties: { name: { type: "string" } }, required: ["name"] } },
493
+ launched: { type: "boolean", description: "This call started Fusion." },
494
+ transport: { type: "string" },
495
+ pin: { type: "string", description: "The faust-machines/fusion360-mcp-server commit this bridge is set up for." },
496
+ server: { type: "object" },
497
+ error: { type: "string" },
498
+ },
499
+ required: ["configured", "connected", "addinReachable", "fusionRunning", "tools"],
500
+ };
501
+ /** fusion_mcp_call passes the remote result through: its structuredContent, else an empty object. */
502
+ export const FUSION_CALL_OUTPUT_SCHEMA = { type: "object" };
503
+ export const FUSION_NEW_DESIGN_OUTPUT_SCHEMA = {
504
+ type: "object",
505
+ properties: {
506
+ status: { type: "string", const: "created" },
507
+ document: { type: "string" },
508
+ parametric: { type: "boolean" },
509
+ },
510
+ required: ["status", "document", "parametric"],
511
+ };
512
+ export const FUSION_EXPORT_OUTPUT_SCHEMA = {
513
+ type: "object",
514
+ properties: {
515
+ status: { type: "string", const: "exported" },
516
+ stl: { type: "string", description: "Absolute path of <name>.stl." },
517
+ f3d: { type: "string", description: "Absolute path of <name>.f3d." },
518
+ parameters: { type: "string", description: "Absolute path of <name>.parameters.json." },
519
+ body: { type: "string" },
520
+ triangles: { type: "integer", minimum: 1 },
521
+ bytes: { type: "integer", minimum: 1 },
522
+ parameterCount: { type: "integer", minimum: 0 },
523
+ boundingBox: { type: "object", description: "Measured from the written STL, in its own units." },
524
+ stlUnits: { type: "string", enum: ["mm", "cm", "unknown"], description: "The STL's units, from its size against the Fusion body's." },
525
+ fusionSizeMm: { type: "array", items: { type: "number" }, description: "The Fusion body's bounding-box size in mm." },
526
+ warnings: { type: "array", items: { type: "string" } },
527
+ },
528
+ required: ["status", "stl", "f3d", "parameters", "body", "triangles", "parameterCount"],
529
+ };