mcp-3d-printer-server 1.2.9 → 1.2.12
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.
- package/CHANGELOG.md +222 -0
- package/CONTRIBUTORS.md +56 -0
- package/README.md +528 -811
- package/dist/3mf_parser.js +8 -8
- package/dist/blender-mcp-bridge.d.ts +1 -0
- package/dist/blender-mcp-bridge.js +209 -21
- package/dist/fulu-orca.js +29 -5
- package/dist/index.js +614 -110
- package/dist/printers/bambu-node-guard.d.ts +2 -0
- package/dist/printers/bambu-node-guard.js +38 -0
- package/dist/printers/bambu.d.ts +53 -7
- package/dist/printers/bambu.js +420 -126
- package/dist/printers/creality.d.ts +15 -6
- package/dist/printers/creality.js +42 -9
- package/dist/printers/duet.d.ts +11 -6
- package/dist/printers/duet.js +57 -34
- package/dist/printers/generic-base.d.ts +69 -0
- package/dist/printers/generic-base.js +209 -0
- package/dist/printers/klipper.d.ts +17 -6
- package/dist/printers/klipper.js +63 -16
- package/dist/printers/octoprint.d.ts +18 -6
- package/dist/printers/octoprint.js +64 -9
- package/dist/printers/printer-factory.d.ts +11 -2
- package/dist/printers/printer-factory.js +9 -8
- package/dist/printers/prusa.d.ts +14 -6
- package/dist/printers/prusa.js +56 -9
- package/dist/printers/repetier.d.ts +18 -6
- package/dist/printers/repetier.js +48 -6
- package/dist/safety/archive.d.ts +11 -0
- package/dist/safety/archive.js +214 -0
- package/dist/safety/artifact.d.ts +6 -0
- package/dist/safety/artifact.js +66 -0
- package/dist/safety/confirmation.d.ts +22 -0
- package/dist/safety/confirmation.js +43 -0
- package/dist/safety/expected-peaks.d.ts +24 -0
- package/dist/safety/expected-peaks.js +24 -0
- package/dist/safety/gcode-thermal.d.ts +64 -0
- package/dist/safety/gcode-thermal.js +382 -0
- package/dist/safety/limits.d.ts +31 -0
- package/dist/safety/limits.js +105 -0
- package/dist/safety/print-file.d.ts +25 -0
- package/dist/safety/print-file.js +610 -0
- package/dist/safety/printer-state.d.ts +51 -0
- package/dist/safety/printer-state.js +371 -0
- package/dist/slicer/bambu-cli.d.ts +110 -0
- package/dist/slicer/bambu-cli.js +547 -0
- package/dist/slicer/profile-flatten.d.ts +104 -0
- package/dist/slicer/profile-flatten.js +676 -0
- package/dist/slicer/run-slicer.d.ts +18 -0
- package/dist/slicer/run-slicer.js +102 -0
- package/dist/slicer/slicer-error.d.ts +27 -0
- package/dist/slicer/slicer-error.js +53 -0
- package/dist/slicer/templates.d.ts +126 -0
- package/dist/slicer/templates.js +255 -0
- package/dist/stl/stl-manipulator.d.ts +25 -3
- package/dist/stl/stl-manipulator.js +214 -232
- package/package.json +16 -9
- package/server.json +56 -0
- package/src/3mf_parser.ts +8 -8
- package/src/blender-mcp-bridge.ts +200 -19
- package/src/fulu-orca.ts +36 -6
- package/src/index.ts +686 -137
- package/src/printers/bambu-node-guard.ts +44 -0
- package/src/printers/bambu.ts +458 -164
- package/src/printers/creality.ts +40 -9
- package/src/printers/duet.ts +64 -53
- package/src/printers/generic-base.ts +286 -0
- package/src/printers/klipper.ts +83 -28
- package/src/printers/octoprint.ts +74 -21
- package/src/printers/printer-factory.ts +21 -11
- package/src/printers/prusa.ts +53 -10
- package/src/printers/repetier.ts +55 -17
- package/src/safety/archive.ts +181 -0
- package/src/safety/artifact.ts +58 -0
- package/src/safety/confirmation.ts +56 -0
- package/src/safety/expected-peaks.ts +47 -0
- package/src/safety/gcode-thermal.ts +404 -0
- package/src/safety/limits.ts +98 -0
- package/src/safety/print-file.ts +429 -0
- package/src/safety/printer-state.ts +344 -0
- package/src/slicer/bambu-cli.ts +633 -0
- package/src/slicer/profile-flatten.ts +830 -0
- package/src/slicer/run-slicer.ts +126 -0
- package/src/slicer/slicer-error.ts +72 -0
- package/src/slicer/templates.ts +288 -0
- package/src/stl/stl-manipulator.ts +247 -275
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcp-3d-printer-server",
|
|
3
|
-
"
|
|
3
|
+
"mcpName": "io.github.DMontgomery40/mcp-3d-printer-server",
|
|
4
|
+
"version": "1.2.12",
|
|
4
5
|
"description": "MCP server for connecting Claude with 3D printer management systems",
|
|
5
6
|
"main": "dist/index.js",
|
|
6
7
|
"repository": "https://github.com/DMontgomery40/mcp-3D-printer-server",
|
|
@@ -11,10 +12,11 @@
|
|
|
11
12
|
"start:http": "MCP_TRANSPORT=streamable-http node dist/index.js",
|
|
12
13
|
"dev": "nodemon src/index.ts --exec \"npm run build && npm start\"",
|
|
13
14
|
"build": "tsc",
|
|
14
|
-
"test": "npm run build && node dist/test/stl-manipulator-test.js && node --test tests/*.test.mjs",
|
|
15
|
+
"test": "npm run build && node dist/test/stl-manipulator-test.js && node --test tests/*.test.mjs tests/slicer/*.test.mjs tests/safety/*.test.mjs",
|
|
15
16
|
"inspect": "npm run build && npx @modelcontextprotocol/inspector dist/index.js",
|
|
16
17
|
"prepack": "npm run build",
|
|
17
|
-
"test:package": "node scripts/test-package.mjs"
|
|
18
|
+
"test:package": "node scripts/test-package.mjs",
|
|
19
|
+
"test:blender-live": "npm run build && node scripts/blender-live-check.mjs"
|
|
18
20
|
},
|
|
19
21
|
"bin": {
|
|
20
22
|
"mcp-3d-printer-server": "./dist/index.js",
|
|
@@ -31,8 +33,10 @@
|
|
|
31
33
|
"dist/index.d.ts",
|
|
32
34
|
"dist/index.js",
|
|
33
35
|
"dist/printers",
|
|
36
|
+
"dist/safety",
|
|
34
37
|
"dist/sse-server.d.ts",
|
|
35
38
|
"dist/sse-server.js",
|
|
39
|
+
"dist/slicer",
|
|
36
40
|
"dist/stl",
|
|
37
41
|
"dist/test",
|
|
38
42
|
"dist/types.d.ts",
|
|
@@ -42,7 +46,9 @@
|
|
|
42
46
|
"src/http-server.ts",
|
|
43
47
|
"src/index.ts",
|
|
44
48
|
"src/printers",
|
|
49
|
+
"src/safety",
|
|
45
50
|
"src/sse-server.ts",
|
|
51
|
+
"src/slicer",
|
|
46
52
|
"src/stl",
|
|
47
53
|
"src/test/3DBenchy.3mf",
|
|
48
54
|
"src/test/stl-manipulator-test.js",
|
|
@@ -54,7 +60,9 @@
|
|
|
54
60
|
"dist/blender-mcp-bridge.js",
|
|
55
61
|
"dist/blender-mcp-bridge.d.ts",
|
|
56
62
|
"src/blender-mcp-bridge.ts",
|
|
57
|
-
"CHANGELOG.md"
|
|
63
|
+
"CHANGELOG.md",
|
|
64
|
+
"CONTRIBUTORS.md",
|
|
65
|
+
"server.json"
|
|
58
66
|
],
|
|
59
67
|
"keywords": [
|
|
60
68
|
"mcp",
|
|
@@ -71,14 +79,13 @@
|
|
|
71
79
|
"author": "David Montgomery",
|
|
72
80
|
"license": "GPL-2.0",
|
|
73
81
|
"dependencies": {
|
|
74
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
82
|
+
"@modelcontextprotocol/sdk": "^1.32.1",
|
|
75
83
|
"axios": "^1.20.0",
|
|
76
|
-
"bambu-js": "^2.2.2",
|
|
77
84
|
"bambu-node": "3.22.21",
|
|
78
|
-
"basic-ftp": "^
|
|
79
|
-
"dotenv": "^16.
|
|
85
|
+
"basic-ftp": "^6.2.3",
|
|
86
|
+
"dotenv": "^16.6.1",
|
|
80
87
|
"form-data": "^4.0.6",
|
|
81
|
-
"jszip": "^3.10.
|
|
88
|
+
"jszip": "^3.10.2",
|
|
82
89
|
"three": "^0.173.0",
|
|
83
90
|
"xml2js": "^0.6.2",
|
|
84
91
|
"zod": "^3.25.76"
|
package/server.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
|
+
"name": "io.github.DMontgomery40/mcp-3d-printer-server",
|
|
4
|
+
"title": "MCP 3D Printer Server",
|
|
5
|
+
"description": "Control 3D printers, edit STL/3MF models, slice files, and connect Blender through MCP.",
|
|
6
|
+
"repository": {
|
|
7
|
+
"url": "https://github.com/DMontgomery40/mcp-3D-printer-server",
|
|
8
|
+
"source": "github"
|
|
9
|
+
},
|
|
10
|
+
"version": "1.2.12",
|
|
11
|
+
"packages": [
|
|
12
|
+
{
|
|
13
|
+
"registryType": "npm",
|
|
14
|
+
"identifier": "mcp-3d-printer-server",
|
|
15
|
+
"version": "1.2.12",
|
|
16
|
+
"runtimeHint": "npx",
|
|
17
|
+
"transport": {
|
|
18
|
+
"type": "stdio"
|
|
19
|
+
},
|
|
20
|
+
"environmentVariables": [
|
|
21
|
+
{
|
|
22
|
+
"name": "PRINTER_TYPE",
|
|
23
|
+
"description": "Printer backend: octoprint, klipper, duet, repetier, bambu, prusa, or creality.",
|
|
24
|
+
"isRequired": true
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"name": "PRINTER_HOST",
|
|
28
|
+
"description": "Printer IP address or hostname.",
|
|
29
|
+
"isRequired": true
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"name": "PRINTER_PORT",
|
|
33
|
+
"description": "Optional backend-specific port."
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"name": "API_KEY",
|
|
37
|
+
"description": "API key for printer backends that require authentication.",
|
|
38
|
+
"isSecret": true
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"name": "BAMBU_SERIAL",
|
|
42
|
+
"description": "Printer serial number; required for Bambu Lab."
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"name": "BAMBU_TOKEN",
|
|
46
|
+
"description": "LAN access code; required for Bambu Lab.",
|
|
47
|
+
"isSecret": true
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
"name": "BAMBU_MODEL",
|
|
51
|
+
"description": "Exact Bambu model; required for Bambu print operations."
|
|
52
|
+
}
|
|
53
|
+
]
|
|
54
|
+
}
|
|
55
|
+
]
|
|
56
|
+
}
|
package/src/3mf_parser.ts
CHANGED
|
@@ -81,7 +81,7 @@ async function parseBambuConfig(zip: JSZip): Promise<Partial<BambuSlicerConfig>>
|
|
|
81
81
|
if (file) {
|
|
82
82
|
configFile = file;
|
|
83
83
|
configContent = await file.async('string');
|
|
84
|
-
console.
|
|
84
|
+
console.error(`Found Bambu config file: ${configFile.name}`);
|
|
85
85
|
break; // Use the first one found (project usually has the most)
|
|
86
86
|
}
|
|
87
87
|
}
|
|
@@ -91,14 +91,14 @@ async function parseBambuConfig(zip: JSZip): Promise<Partial<BambuSlicerConfig>>
|
|
|
91
91
|
// Attempt to parse as JSON
|
|
92
92
|
const jsonData = JSON.parse(configContent);
|
|
93
93
|
const parsedConfig = parseBambuJSONConfig(jsonData);
|
|
94
|
-
console.
|
|
94
|
+
console.error('Bambu config parsed successfully as JSON.');
|
|
95
95
|
return parsedConfig;
|
|
96
96
|
} catch (jsonError: any) {
|
|
97
97
|
console.warn(`Failed to parse ${configFile.name} as JSON: ${jsonError.message}. Attempting INI parse as fallback...`);
|
|
98
98
|
// Fallback: Try parsing as INI if JSON fails (though unlikely based on sample)
|
|
99
99
|
try {
|
|
100
100
|
const parsedConfig = parseFallbackINIConfig(configContent); // Keep a simple INI parser as fallback
|
|
101
|
-
console.
|
|
101
|
+
console.error('Bambu config parsed successfully as INI (fallback).');
|
|
102
102
|
return parsedConfig;
|
|
103
103
|
} catch (iniError: any) {
|
|
104
104
|
console.error(`Error parsing Bambu config ${configFile.name} as INI (fallback):`, iniError);
|
|
@@ -106,7 +106,7 @@ async function parseBambuConfig(zip: JSZip): Promise<Partial<BambuSlicerConfig>>
|
|
|
106
106
|
}
|
|
107
107
|
}
|
|
108
108
|
} else {
|
|
109
|
-
console.
|
|
109
|
+
console.error('No Bambu-specific config file found in Metadata directory.');
|
|
110
110
|
return {};
|
|
111
111
|
}
|
|
112
112
|
}
|
|
@@ -181,7 +181,7 @@ async function parse3DModelConfig(zip: JSZip): Promise<{ metadata: ThreeMFMetada
|
|
|
181
181
|
});
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
-
console.
|
|
184
|
+
console.error('3dmodel.model parsed successfully.');
|
|
185
185
|
return { metadata, objects, build: { items: buildItems } };
|
|
186
186
|
|
|
187
187
|
} catch (error: any) {
|
|
@@ -193,14 +193,14 @@ async function parse3DModelConfig(zip: JSZip): Promise<{ metadata: ThreeMFMetada
|
|
|
193
193
|
export { ThreeMFData };
|
|
194
194
|
|
|
195
195
|
export async function parse3MF(filePath: string): Promise<ThreeMFData> {
|
|
196
|
-
console.
|
|
196
|
+
console.error(`Parsing 3MF file: ${filePath}`);
|
|
197
197
|
try {
|
|
198
198
|
const data = await fs.readFile(filePath);
|
|
199
199
|
const zip = await JSZip.loadAsync(data);
|
|
200
200
|
|
|
201
201
|
// List files for debugging
|
|
202
202
|
// const fileList = Object.keys(zip.files);
|
|
203
|
-
// console.
|
|
203
|
+
// console.error('Files in 3MF:', fileList);
|
|
204
204
|
|
|
205
205
|
const { metadata, objects, build } = await parse3DModelConfig(zip);
|
|
206
206
|
const bambuConfig = await parseBambuConfig(zip);
|
|
@@ -213,7 +213,7 @@ export async function parse3MF(filePath: string): Promise<ThreeMFData> {
|
|
|
213
213
|
slicerConfig: bambuConfig
|
|
214
214
|
};
|
|
215
215
|
|
|
216
|
-
console.
|
|
216
|
+
console.error('3MF parsing completed.');
|
|
217
217
|
return combinedData;
|
|
218
218
|
|
|
219
219
|
} catch (error: any) {
|
|
@@ -14,11 +14,15 @@ const execAsync = promisify(exec);
|
|
|
14
14
|
const MAX_STL_BYTES = 256 * 1024 * 1024;
|
|
15
15
|
const MAX_ASCII_STL_BYTES = 4 * 1024 * 1024;
|
|
16
16
|
const RECEIPT_PREFIX = "BAMBU_STL_RESULT:";
|
|
17
|
+
const EXPORT_RECEIPT_PREFIX = "MCP3D_STL_EXPORT:";
|
|
17
18
|
type Arguments = Record<string, unknown>;
|
|
18
19
|
type Operation = { type: "decimate"; ratio: number } | { type: "remesh"; voxelSize: number } | { type: "boolean_union"; path: string };
|
|
19
20
|
type EditPlan = { requestId: string; stlPath: string; outputPath: string; stagedOutputPath: string; operations: Operation[] };
|
|
20
21
|
type Session = { client: Client; tools: Tool[]; options: () => { signal: AbortSignal; timeout: number }; markDispatched: () => void };
|
|
21
22
|
type EditDeadline = { check: () => void; remaining: () => number };
|
|
23
|
+
type ExportPlan = { requestId: string; objectNames: string[]; outputPath: string; stagedOutputPath: string; applyModifiers: boolean; scale: number };
|
|
24
|
+
type BoundingBox = { min: [number, number, number]; max: [number, number, number]; dimensions: [number, number, number] };
|
|
25
|
+
type StlMetadata = { bytes: number; triangles: number; bounding_box: BoundingBox };
|
|
22
26
|
|
|
23
27
|
class BlenderError extends Error {}
|
|
24
28
|
|
|
@@ -74,7 +78,7 @@ function timeoutMilliseconds(value: unknown): number {
|
|
|
74
78
|
|
|
75
79
|
function standardConfiguration(): { command: string; args: string[] } {
|
|
76
80
|
const command = process.env.BLENDER_MCP_COMMAND?.trim();
|
|
77
|
-
if (!command) throw new BlenderError("No Blender MCP server configured. Set BLENDER_MCP_COMMAND to an executable and BLENDER_MCP_ARGS to a JSON string array (for example uvx and [\"blender
|
|
81
|
+
if (!command) throw new BlenderError("No Blender MCP server configured. Set BLENDER_MCP_COMMAND to an executable and BLENDER_MCP_ARGS to a JSON string array (for example uvx and [\"mcp-for-blender\"]; the older blender-mcp package name still works). Open Blender with the matching addon connected; the addon does not run in background mode.");
|
|
78
82
|
let args: unknown;
|
|
79
83
|
try { args = JSON.parse(process.env.BLENDER_MCP_ARGS?.trim() || "[]"); }
|
|
80
84
|
catch { throw new BlenderError("BLENDER_MCP_ARGS must be a JSON array of strings, not a shell command."); }
|
|
@@ -107,7 +111,16 @@ function normalizeToolFailure(result: CallToolResult): CallToolResult {
|
|
|
107
111
|
return failed ? { ...result, isError: true } : result;
|
|
108
112
|
}
|
|
109
113
|
|
|
110
|
-
|
|
114
|
+
function boundingBox(min: number[], max: number[]): BoundingBox {
|
|
115
|
+
const round = (value: number) => Math.round(value * 1e4) / 1e4;
|
|
116
|
+
return {
|
|
117
|
+
min: [round(min[0]), round(min[1]), round(min[2])],
|
|
118
|
+
max: [round(max[0]), round(max[1]), round(max[2])],
|
|
119
|
+
dimensions: [round(max[0] - min[0]), round(max[1] - min[1]), round(max[2] - min[2])],
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function regularStl(filePath: string, deadline: EditDeadline): Promise<StlMetadata> {
|
|
111
124
|
deadline.check();
|
|
112
125
|
const stat = await fs.lstat(filePath).catch(() => { throw new BlenderError("STL file does not exist or is not readable."); });
|
|
113
126
|
deadline.check();
|
|
@@ -135,17 +148,23 @@ async function regularStl(filePath: string, deadline: EditDeadline): Promise<{ b
|
|
|
135
148
|
// Validate binary records directly; constructing a Three.js geometry would
|
|
136
149
|
// expand a 256 MiB input into hundreds of MiB of positions/normals/colors.
|
|
137
150
|
const chunk = Buffer.alloc(50 * 1024);
|
|
151
|
+
const min = [Infinity, Infinity, Infinity];
|
|
152
|
+
const max = [-Infinity, -Infinity, -Infinity];
|
|
138
153
|
for (let position = 84; position < stat.size; position += chunk.length) {
|
|
139
154
|
const length = Math.min(chunk.length, stat.size - position);
|
|
140
155
|
await read(chunk, position, length);
|
|
141
156
|
for (let face = 0; face < length; face += 50) {
|
|
142
157
|
for (let coordinate = 12; coordinate < 48; coordinate += 4) {
|
|
143
|
-
|
|
158
|
+
const value = chunk.readFloatLE(face + coordinate);
|
|
159
|
+
if (!Number.isFinite(value)) throw new Error("invalid vertices");
|
|
160
|
+
const axis = ((coordinate - 12) / 4) % 3;
|
|
161
|
+
if (value < min[axis]) min[axis] = value;
|
|
162
|
+
if (value > max[axis]) max[axis] = value;
|
|
144
163
|
}
|
|
145
164
|
}
|
|
146
165
|
}
|
|
147
166
|
deadline.check();
|
|
148
|
-
return { bytes: stat.size, triangles };
|
|
167
|
+
return { bytes: stat.size, triangles, bounding_box: boundingBox(min, max) };
|
|
149
168
|
}
|
|
150
169
|
// Reject malformed binary headers before a parser can allocate arrays from
|
|
151
170
|
// an untrusted triangle count. ASCII parsing has its own smaller bound.
|
|
@@ -159,8 +178,10 @@ async function regularStl(filePath: string, deadline: EditDeadline): Promise<{ b
|
|
|
159
178
|
geometry = new STLLoader().parse(buffer.buffer as ArrayBuffer);
|
|
160
179
|
const positions = geometry.getAttribute("position");
|
|
161
180
|
if (!positions || positions.count < 3 || positions.count % 3 !== 0 || !positions.array.every(Number.isFinite)) throw new Error("invalid vertices");
|
|
181
|
+
geometry.computeBoundingBox();
|
|
182
|
+
const box = geometry.boundingBox!;
|
|
162
183
|
deadline.check();
|
|
163
|
-
return { bytes: stat.size, triangles: positions.count / 3 };
|
|
184
|
+
return { bytes: stat.size, triangles: positions.count / 3, bounding_box: boundingBox(box.min.toArray(), box.max.toArray()) };
|
|
164
185
|
} catch (error) {
|
|
165
186
|
if (error instanceof BlenderError) throw error;
|
|
166
187
|
throw new BlenderError("STL contains no valid finite triangle mesh.");
|
|
@@ -181,17 +202,7 @@ async function inputPath(value: unknown, label: string, deadline: EditDeadline):
|
|
|
181
202
|
|
|
182
203
|
async function editPlan(args: Arguments, deadline: EditDeadline): Promise<EditPlan> {
|
|
183
204
|
const stlPath = await inputPath(args.stl_path, "stl_path", deadline);
|
|
184
|
-
const
|
|
185
|
-
? path.join(path.dirname(stlPath), `model-edited-${randomUUID()}.stl`)
|
|
186
|
-
: path.resolve(textArgument(args.output_path, "output_path"));
|
|
187
|
-
if (path.extname(requestedOutput).toLowerCase() !== ".stl") throw new BlenderError("output_path must name a new STL file.");
|
|
188
|
-
const directory = await fs.realpath(path.dirname(requestedOutput)).catch(() => { throw new BlenderError("output_path parent directory must already exist."); });
|
|
189
|
-
const outputPath = path.join(directory, path.basename(requestedOutput));
|
|
190
|
-
const existing = await fs.lstat(outputPath).catch((error: NodeJS.ErrnoException) => {
|
|
191
|
-
if (error.code !== "ENOENT") throw new BlenderError("output_path cannot be accessed.");
|
|
192
|
-
return undefined;
|
|
193
|
-
});
|
|
194
|
-
if (existing) throw new BlenderError("output_path already exists; choose a new file. Input files are never overwritten.");
|
|
205
|
+
const { outputPath, directory } = await newStlOutput(args.output_path ?? path.join(path.dirname(stlPath), `model-edited-${randomUUID()}.stl`), deadline);
|
|
195
206
|
const operations: Operation[] = [];
|
|
196
207
|
for (const operation of args.operations as string[]) {
|
|
197
208
|
deadline.check();
|
|
@@ -212,6 +223,119 @@ async function editPlan(args: Arguments, deadline: EditDeadline): Promise<EditPl
|
|
|
212
223
|
return { requestId, stlPath, outputPath, stagedOutputPath: path.join(directory, `.bambu-${requestId}.stl`), operations };
|
|
213
224
|
}
|
|
214
225
|
|
|
226
|
+
async function newStlOutput(value: unknown, deadline: EditDeadline): Promise<{ outputPath: string; directory: string }> {
|
|
227
|
+
deadline.check();
|
|
228
|
+
const requestedOutput = path.resolve(textArgument(value, "output_path"));
|
|
229
|
+
if (path.extname(requestedOutput).toLowerCase() !== ".stl") throw new BlenderError("output_path must name a new STL file.");
|
|
230
|
+
const directory = await fs.realpath(path.dirname(requestedOutput)).catch(() => { throw new BlenderError("output_path parent directory must already exist."); });
|
|
231
|
+
const outputPath = path.join(directory, path.basename(requestedOutput));
|
|
232
|
+
const existing = await fs.lstat(outputPath).catch((error: NodeJS.ErrnoException) => {
|
|
233
|
+
if (error.code !== "ENOENT") throw new BlenderError("output_path cannot be accessed.");
|
|
234
|
+
return undefined;
|
|
235
|
+
});
|
|
236
|
+
if (existing) throw new BlenderError("output_path already exists; choose a new file. Existing files are never overwritten.");
|
|
237
|
+
return { outputPath, directory };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function exportPlan(args: Arguments, deadline: EditDeadline): Promise<ExportPlan> {
|
|
241
|
+
const names = args.object_names;
|
|
242
|
+
if (!Array.isArray(names) || names.length === 0 || names.length > 64 ||
|
|
243
|
+
names.some((name) => typeof name !== "string" || !name.trim() || name.length > 256 || name.includes("\0")) ||
|
|
244
|
+
new Set(names).size !== names.length) {
|
|
245
|
+
throw new BlenderError("object_names must list 1 to 64 distinct Blender object names.");
|
|
246
|
+
}
|
|
247
|
+
const scale = args.scale ?? 1;
|
|
248
|
+
if (typeof scale !== "number" || !Number.isFinite(scale) || scale <= 0 || scale > 1_000_000) throw new BlenderError("scale must be a finite number greater than 0.");
|
|
249
|
+
const { outputPath, directory } = await newStlOutput(args.output_path, deadline);
|
|
250
|
+
const requestId = randomUUID();
|
|
251
|
+
return { requestId, objectNames: names as string[], outputPath, stagedOutputPath: path.join(directory, `.mcp3d-${requestId}.stl`),
|
|
252
|
+
applyModifiers: booleanArgument(args.apply_modifiers, "apply_modifiers", true), scale };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function exportScript(plan: ExportPlan): string {
|
|
256
|
+
// Writes world-space triangles directly instead of calling an exporter, so the
|
|
257
|
+
// user's selection, active object and mode are never changed. JSON is data only.
|
|
258
|
+
return `# MCP3D_EXPORT_PLAN ${JSON.stringify(plan)}
|
|
259
|
+
import bpy
|
|
260
|
+
import json
|
|
261
|
+
import math
|
|
262
|
+
import os
|
|
263
|
+
import struct
|
|
264
|
+
|
|
265
|
+
def _mcp3d_export():
|
|
266
|
+
plan = json.loads(${JSON.stringify(JSON.stringify(plan))})
|
|
267
|
+
if bpy.context.mode != 'OBJECT':
|
|
268
|
+
raise RuntimeError('Switch Blender to Object Mode before exporting an STL')
|
|
269
|
+
objects = []
|
|
270
|
+
for name in plan['objectNames']:
|
|
271
|
+
obj = bpy.data.objects.get(name)
|
|
272
|
+
if obj is None:
|
|
273
|
+
raise RuntimeError('No Blender object named ' + json.dumps(name))
|
|
274
|
+
if obj.type not in {'MESH', 'CURVE', 'SURFACE', 'META', 'FONT'}:
|
|
275
|
+
raise RuntimeError(json.dumps(name) + ' is a ' + obj.type + ' object, not printable geometry')
|
|
276
|
+
if not plan['applyModifiers'] and obj.type != 'MESH':
|
|
277
|
+
raise RuntimeError(json.dumps(name) + ' needs apply_modifiers to be converted to a mesh')
|
|
278
|
+
objects.append(obj)
|
|
279
|
+
depsgraph = bpy.context.evaluated_depsgraph_get()
|
|
280
|
+
scale = float(plan['scale'])
|
|
281
|
+
lower = [math.inf] * 3
|
|
282
|
+
upper = [-math.inf] * 3
|
|
283
|
+
triangles = 0
|
|
284
|
+
exported = []
|
|
285
|
+
finished = False
|
|
286
|
+
try:
|
|
287
|
+
with open(plan['stagedOutputPath'], 'xb') as handle:
|
|
288
|
+
handle.write(b'mcp-3d-printer-server Blender STL export'.ljust(80, b' '))
|
|
289
|
+
handle.write(struct.pack('<I', 0))
|
|
290
|
+
for obj in objects:
|
|
291
|
+
evaluated = obj.evaluated_get(depsgraph)
|
|
292
|
+
matrix = evaluated.matrix_world
|
|
293
|
+
owner = evaluated if plan['applyModifiers'] else None
|
|
294
|
+
mesh = evaluated.to_mesh() if owner else obj.data.copy()
|
|
295
|
+
try:
|
|
296
|
+
mesh.calc_loop_triangles()
|
|
297
|
+
mirrored = matrix.determinant() < 0
|
|
298
|
+
count = 0
|
|
299
|
+
for tri in mesh.loop_triangles:
|
|
300
|
+
points = [matrix @ mesh.vertices[index].co * scale for index in tri.vertices]
|
|
301
|
+
if mirrored:
|
|
302
|
+
points = [points[0], points[2], points[1]]
|
|
303
|
+
for point in points:
|
|
304
|
+
for axis in range(3):
|
|
305
|
+
value = point[axis]
|
|
306
|
+
if not math.isfinite(value):
|
|
307
|
+
raise RuntimeError('Non-finite vertex in ' + json.dumps(obj.name))
|
|
308
|
+
lower[axis] = min(lower[axis], value)
|
|
309
|
+
upper[axis] = max(upper[axis], value)
|
|
310
|
+
normal = (points[1] - points[0]).cross(points[2] - points[0])
|
|
311
|
+
normal = normal.normalized() if normal.length > 0 else normal
|
|
312
|
+
handle.write(struct.pack('<12fH', *normal, *points[0], *points[1], *points[2], 0))
|
|
313
|
+
count += 1
|
|
314
|
+
triangles += count
|
|
315
|
+
exported.append({'name': obj.name, 'type': obj.type, 'triangles': count})
|
|
316
|
+
finally:
|
|
317
|
+
if owner:
|
|
318
|
+
owner.to_mesh_clear()
|
|
319
|
+
else:
|
|
320
|
+
bpy.data.meshes.remove(mesh)
|
|
321
|
+
if triangles == 0:
|
|
322
|
+
raise RuntimeError('The selected objects contain no triangles')
|
|
323
|
+
handle.seek(80)
|
|
324
|
+
handle.write(struct.pack('<I', triangles))
|
|
325
|
+
finished = True
|
|
326
|
+
finally:
|
|
327
|
+
if not finished and os.path.exists(plan['stagedOutputPath']):
|
|
328
|
+
os.remove(plan['stagedOutputPath'])
|
|
329
|
+
units = bpy.context.scene.unit_settings
|
|
330
|
+
print('${EXPORT_RECEIPT_PREFIX}' + json.dumps({'requestId': plan['requestId'], 'outputPath': plan['stagedOutputPath'],
|
|
331
|
+
'triangles': triangles, 'min': lower, 'max': upper, 'objects': exported,
|
|
332
|
+
'sceneUnits': {'system': units.system, 'scaleLength': units.scale_length, 'lengthUnit': units.length_unit}}))
|
|
333
|
+
|
|
334
|
+
_mcp3d_export()
|
|
335
|
+
del _mcp3d_export
|
|
336
|
+
`;
|
|
337
|
+
}
|
|
338
|
+
|
|
215
339
|
function editScript(plan: EditPlan): string {
|
|
216
340
|
// JSON is data, never interpolated Python syntax. The comment is also useful in dry runs.
|
|
217
341
|
return `# BAMBU_EDIT_PLAN ${JSON.stringify(plan)}
|
|
@@ -353,17 +477,74 @@ export class BlenderMcpBridge {
|
|
|
353
477
|
let valid: boolean;
|
|
354
478
|
try { valid = new AjvJsonSchemaValidator().getValidator(tool.inputSchema)(args).valid; }
|
|
355
479
|
catch { throw new BlenderError("Blender MCP advertised an unsupported input schema for this tool."); }
|
|
356
|
-
if (!valid) throw new BlenderError("Blender tool arguments do not match its advertised input schema.
|
|
480
|
+
if (!valid) throw new BlenderError("Blender tool arguments do not match its advertised input schema. Call blender_mcp_status with connect: true and tool_names: [\"<tool>\"] to read the schema.");
|
|
357
481
|
session.markDispatched();
|
|
358
482
|
return normalizeToolFailure(await session.client.callTool({ name, arguments: args }, undefined, session.options()) as CallToolResult);
|
|
359
483
|
}
|
|
360
484
|
|
|
361
485
|
async status(args: Arguments, signal?: AbortSignal): Promise<unknown> {
|
|
362
|
-
allowedArguments(args, ["connect", "timeout_ms"]);
|
|
486
|
+
allowedArguments(args, ["connect", "timeout_ms", "tool_names", "include_schemas"]);
|
|
363
487
|
const connect = booleanArgument(args.connect, "connect");
|
|
364
488
|
const timeout = timeoutMilliseconds(args.timeout_ms);
|
|
489
|
+
const includeSchemas = booleanArgument(args.include_schemas, "include_schemas");
|
|
490
|
+
const requested = args.tool_names;
|
|
491
|
+
if (requested !== undefined && (!Array.isArray(requested) || requested.length > 64 || requested.some((name) => typeof name !== "string" || !name.trim()))) {
|
|
492
|
+
throw new BlenderError("tool_names must be an array of up to 64 tool names.");
|
|
493
|
+
}
|
|
365
494
|
if (!connect) return { configured: Boolean(process.env.BLENDER_MCP_COMMAND?.trim()), legacy_configured: Boolean(process.env.BLENDER_MCP_BRIDGE_COMMAND?.trim()), connected: false, transport: "stdio" };
|
|
366
|
-
return this.session(timeout, signal, async ({ client, tools }) =>
|
|
495
|
+
return this.session(timeout, signal, async ({ client, tools }) => {
|
|
496
|
+
// Full Blender MCP schemas are tens of kilobytes; list names by default and
|
|
497
|
+
// return complete definitions only for the tools an agent is about to call.
|
|
498
|
+
const wanted = new Set(requested as string[] | undefined);
|
|
499
|
+
const unknown = [...wanted].filter((name) => !tools.some((tool) => tool.name === name));
|
|
500
|
+
const summary = (tool: Tool) => ({ name: tool.name, description: tool.description?.trim().split(/\n\s*\n/)[0].replace(/\s+/g, " ").slice(0, 300) });
|
|
501
|
+
return {
|
|
502
|
+
configured: true, connected: true, transport: "stdio", server: client.getServerVersion(), capabilities: client.getServerCapabilities(),
|
|
503
|
+
tools: tools.map((tool) => includeSchemas || wanted.has(tool.name) ? tool : summary(tool)),
|
|
504
|
+
schemas_included: includeSchemas ? "all" : [...wanted].filter((name) => !unknown.includes(name)),
|
|
505
|
+
...(unknown.length ? { unknown_tool_names: unknown } : {}),
|
|
506
|
+
note: "Pass tool_names (or include_schemas: true) to receive full input schemas. Verified STL export: blender_mcp_export_stl.",
|
|
507
|
+
};
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
async exportStl(args: Arguments, signal?: AbortSignal): Promise<unknown> {
|
|
512
|
+
allowedArguments(args, ["object_names", "output_path", "apply_modifiers", "scale", "user_prompt", "timeout_ms"]);
|
|
513
|
+
const timeout = timeoutMilliseconds(args.timeout_ms);
|
|
514
|
+
const deadline = editDeadline(timeout, signal);
|
|
515
|
+
if (args.user_prompt !== undefined && typeof args.user_prompt !== "string") throw new BlenderError("user_prompt must be a string.");
|
|
516
|
+
standardConfiguration();
|
|
517
|
+
const plan = await exportPlan(args, deadline);
|
|
518
|
+
const code = exportScript(plan);
|
|
519
|
+
try {
|
|
520
|
+
const result = await this.session(deadline.remaining(), signal, (session) => this.invoke(session, "execute_blender_code", { code, user_prompt: args.user_prompt ?? "" }));
|
|
521
|
+
if (result.isError) {
|
|
522
|
+
const detail = result.content.filter((entry) => entry.type === "text").map((entry) => entry.text).join("\n");
|
|
523
|
+
// mcp-for-blender wraps the Python exception as JSON; lead with its message.
|
|
524
|
+
let reason = "";
|
|
525
|
+
try { reason = JSON.parse(`"${detail.match(/"message":\s*"((?:[^"\\]|\\.)*)"/)?.[1] ?? ""}"`); } catch { /* Fall back to the raw text. */ }
|
|
526
|
+
throw new BlenderError(`Blender could not export the requested objects: ${reason || detail.slice(0, 600) || "no details"}. No output was published.`);
|
|
527
|
+
}
|
|
528
|
+
const text = result.content.filter((entry) => entry.type === "text").map((entry) => entry.text).join("\n");
|
|
529
|
+
const receipts = [...text.matchAll(/MCP3D_STL_EXPORT:(\{[^\n]*\})/g)];
|
|
530
|
+
let receipt: any;
|
|
531
|
+
try { receipt = JSON.parse(receipts.at(-1)?.[1] ?? "null"); } catch { /* Invalid receipt is a failure. */ }
|
|
532
|
+
if (receipt?.requestId !== plan.requestId || receipt?.outputPath !== plan.stagedOutputPath) throw new BlenderError("Blender did not return a matching export receipt. Inspect its connection and tool result; no output was published.");
|
|
533
|
+
const metadata = await regularStl(plan.stagedOutputPath, deadline);
|
|
534
|
+
if (metadata.triangles !== receipt.triangles) throw new BlenderError("The exported STL does not match Blender's export receipt; no output was published.");
|
|
535
|
+
deadline.check();
|
|
536
|
+
await fs.link(plan.stagedOutputPath, plan.outputPath).catch(() => { throw new BlenderError("Cannot publish the exported STL; output_path may already exist or be unwritable."); });
|
|
537
|
+
const largest = Math.max(...metadata.bounding_box.dimensions);
|
|
538
|
+
const warnings: string[] = [];
|
|
539
|
+
if (largest < 1) warnings.push("The model is under 1 unit across. Slicers read STL coordinates as millimetres; if it was modelled in metres, export again with scale: 1000.");
|
|
540
|
+
if (largest > 2000) warnings.push("The model is over 2000 units across. Slicers read STL coordinates as millimetres; check the scene units and scale.");
|
|
541
|
+
return {
|
|
542
|
+
status: "success", mode: "mcp", output_path: plan.outputPath, ...metadata, units: "STL coordinates (slicers treat 1 unit as 1 mm)",
|
|
543
|
+
scale: plan.scale, objects: receipt.objects, scene_units: receipt.sceneUnits, output_verified: true, ...(warnings.length ? { warnings } : {}),
|
|
544
|
+
};
|
|
545
|
+
} finally {
|
|
546
|
+
await fs.rm(plan.stagedOutputPath, { force: true }).catch(() => {});
|
|
547
|
+
}
|
|
367
548
|
}
|
|
368
549
|
|
|
369
550
|
async call(args: Arguments, signal?: AbortSignal): Promise<CallToolResult> {
|
package/src/fulu-orca.ts
CHANGED
|
@@ -78,6 +78,23 @@ const FULU_PRINT_RPC_METHODS = new Set([
|
|
|
78
78
|
"net.start_sdcard_print",
|
|
79
79
|
]);
|
|
80
80
|
|
|
81
|
+
/**
|
|
82
|
+
* Agent/session/account bootstrap methods. They do not address a printer, so
|
|
83
|
+
* they remain available with allow_mutating_method=true. Every other method
|
|
84
|
+
* outside the read-only allowlist is refused: raw print starts, raw MQTT
|
|
85
|
+
* messages, file transfers and unknown methods would bypass the inspected
|
|
86
|
+
* print, heating and printer-state gate.
|
|
87
|
+
*/
|
|
88
|
+
const SESSION_MUTATING_METHODS = new Set([
|
|
89
|
+
"net.create_agent",
|
|
90
|
+
"net.start",
|
|
91
|
+
"net.init_log",
|
|
92
|
+
"net.set_config_dir",
|
|
93
|
+
"net.set_country_code",
|
|
94
|
+
"net.connect_server",
|
|
95
|
+
"net.change_user",
|
|
96
|
+
]);
|
|
97
|
+
|
|
81
98
|
export function isFuluPrintRpcMethod(method: string): boolean {
|
|
82
99
|
return FULU_PRINT_RPC_METHODS.has(method.trim());
|
|
83
100
|
}
|
|
@@ -501,15 +518,25 @@ export async function invokeFuluBridgeRpc(options: FuluBridgeRpcOptions): Promis
|
|
|
501
518
|
const readOnly = isReadOnlyBridgeMethod(method);
|
|
502
519
|
const printMethod = isFuluPrintRpcMethod(method);
|
|
503
520
|
|
|
504
|
-
if (
|
|
521
|
+
if (printMethod) {
|
|
505
522
|
throw new Error(
|
|
506
|
-
`FULU bridge method "${method}"
|
|
507
|
-
"
|
|
523
|
+
`Print safety: raw FULU bridge print method "${method}" is disabled because it would start an uninspected job ` +
|
|
524
|
+
"without the thermal, model, nozzle, printer-state and human-confirmation checks. Use print_3mf for a checked LAN print."
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if (!readOnly && !SESSION_MUTATING_METHODS.has(method)) {
|
|
529
|
+
throw new Error(
|
|
530
|
+
`Print safety: raw FULU bridge method "${method}" is not in the verified allowlist. Raw printer messages, ` +
|
|
531
|
+
"file transfers and unknown methods could bypass the print and heating safety gate. Use the dedicated printer tools instead."
|
|
508
532
|
);
|
|
509
533
|
}
|
|
510
534
|
|
|
511
|
-
if (
|
|
512
|
-
throw new Error(
|
|
535
|
+
if (!readOnly && !options.allowMutatingMethod) {
|
|
536
|
+
throw new Error(
|
|
537
|
+
`FULU bridge method "${method}" may mutate account, session, or cloud state. ` +
|
|
538
|
+
"Pass allow_mutating_method=true only when you intend to perform that action."
|
|
539
|
+
);
|
|
513
540
|
}
|
|
514
541
|
|
|
515
542
|
const command = options.bridgeCommand || process.env.FULU_BAMBU_BRIDGE_COMMAND || "";
|
|
@@ -590,11 +617,14 @@ export async function inspectFuluOrcaSetup(options: FuluOrcaSetupOptions): Promi
|
|
|
590
617
|
safeByDefault: true,
|
|
591
618
|
readOnlyMethods: Array.from(READ_ONLY_BRIDGE_METHODS),
|
|
592
619
|
readOnlyNetPrefixes: READ_ONLY_NET_PREFIXES,
|
|
593
|
-
|
|
620
|
+
sessionMethodsRequireAllowMutating: Array.from(SESSION_MUTATING_METHODS),
|
|
621
|
+
disabledPrintMethods: Array.from(FULU_PRINT_RPC_METHODS),
|
|
622
|
+
otherMethods: "disabled: raw printer messages, file transfers and unknown methods bypass the safety gate",
|
|
594
623
|
},
|
|
595
624
|
notes: [
|
|
596
625
|
"Slicing support uses FULU OrcaSlicer-bambulab CLI output as a sliced 3MF.",
|
|
597
626
|
"Bambu print start still enforces BAMBU_MODEL before generating or sending project G-code.",
|
|
627
|
+
"Raw bridge print methods are disabled; print_3mf inspects the exact plate, checks the live printer and asks for human confirmation.",
|
|
598
628
|
"The FULU BambuNetwork bridge is a separate runtime from the LAN MQTT/FTPS path; use bridge probing to verify that runtime before relying on cloud behavior.",
|
|
599
629
|
"FULU macOS release bundles may ship the bridge dylib and host shims before the Linux plugin .so payload is present; missing linux-plugin entries mean the slicer can still be tested, but the BambuNetwork bridge is not fully ready.",
|
|
600
630
|
],
|