fnf-blender-mcp 0.1.0

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/dist/server.js ADDED
@@ -0,0 +1,68 @@
1
+ import { readFile, stat } from "node:fs/promises";
2
+ import { McpServer } from "@modelcontextprotocol/server";
3
+ import { z } from "zod";
4
+ import { BL_TOOLS } from "./tools.js";
5
+ import { BlenderTransport } from "./transport.js";
6
+ import { getSkill, SKILL_NAMES } from "./skills.js";
7
+ const packageInfo = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8"));
8
+ const errorResult = (error) => ({ isError: true, content: [{ type: "text", text: String(error) }] });
9
+ export async function formatResult(job) {
10
+ const data = job.result;
11
+ const object = data && typeof data === "object" && !Array.isArray(data) ? data : undefined;
12
+ const image = object?.pngBase64;
13
+ const { pngBase64: _removed, ...metadata } = object ?? {};
14
+ const structuredContent = { ...job, result: object ? metadata : data ?? null };
15
+ const content = [{ type: "text", text: JSON.stringify(structuredContent) }];
16
+ if (job.ok && typeof image === "string") {
17
+ content.push({ type: "image", data: image, mimeType: "image/png" });
18
+ }
19
+ if (job.ok && typeof object?.output_path === "string") {
20
+ const path = object.output_path;
21
+ try {
22
+ const file = await stat(path);
23
+ if (file.size <= 4 * 1024 * 1024 && path.toLowerCase().endsWith(".png")) {
24
+ content.push({ type: "image", data: (await readFile(path)).toString("base64"), mimeType: "image/png" });
25
+ }
26
+ }
27
+ catch {
28
+ content.push({ type: "text", text: "Preview unavailable; inspect the returned output path." });
29
+ }
30
+ }
31
+ return { content, structuredContent, ...(job.ok === false ? { isError: true } : {}) };
32
+ }
33
+ export function createServer(transport = new BlenderTransport()) {
34
+ const server = new McpServer({ name: "higgsfield-use-blender", title: "Higgsfield use Blender", version: packageInfo.version }, {
35
+ instructions: "Local Blender control. Read bl_get_skill(blender-scene), then bl_health and bl_get_scene_summary before edits. Prefer typed tools; use bl_execute for other bpy operations. Commands can partially mutate before errors. On timeout, query bl_job_status; never blindly retry. Render and view results. Cloud generation is provided by a separate Higgsfield MCP, not this server.",
36
+ });
37
+ for (const tool of BL_TOOLS) {
38
+ server.registerTool(tool.name, {
39
+ title: tool.title, description: tool.description, inputSchema: tool.schema,
40
+ annotations: { readOnlyHint: tool.readOnly, destructiveHint: !tool.readOnly, idempotentHint: tool.readOnly, openWorldHint: tool.name === "bl_execute" },
41
+ }, async (args) => {
42
+ try {
43
+ return await formatResult(await transport.execute(tool.code(args), tool.name === "bl_render" ? 300 : 120));
44
+ }
45
+ catch (error) {
46
+ return errorResult(error);
47
+ }
48
+ });
49
+ }
50
+ server.registerTool("bl_job_status", {
51
+ title: "Check Blender Job", description: "Check a previously accepted job after timeout without repeating its mutations. Select the original Blender PID when multiple instances are open. Completed results expire after 128 newer accepted jobs or Blender exits.",
52
+ inputSchema: z.object({ job_id: z.string().regex(/^[a-f0-9]{32}$/) }).strict(),
53
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
54
+ }, async ({ job_id }) => {
55
+ try {
56
+ return await formatResult(await transport.status(job_id));
57
+ }
58
+ catch (error) {
59
+ return errorResult(error);
60
+ }
61
+ });
62
+ server.registerTool("bl_get_skill", {
63
+ title: "Read Blender Skill", description: "Load offline Blender craft guidance. Start with blender-scene, then read the relevant topic. Does not require Blender to be running.",
64
+ inputSchema: z.object({ name: z.enum(SKILL_NAMES).default("blender-scene") }).strict(),
65
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
66
+ }, async ({ name }) => ({ content: [{ type: "text", text: await getSkill(name) }] }));
67
+ return server;
68
+ }
@@ -0,0 +1,2 @@
1
+ export declare const SKILL_NAMES: readonly ["blender-scene", "modeling", "materials", "lighting-camera", "animation"];
2
+ export declare function getSkill(name: typeof SKILL_NAMES[number]): Promise<string>;
package/dist/skills.js ADDED
@@ -0,0 +1,6 @@
1
+ import { readFile } from "node:fs/promises";
2
+ export const SKILL_NAMES = ["blender-scene", "modeling", "materials", "lighting-camera", "animation"];
3
+ export async function getSkill(name) {
4
+ const relative = name === "blender-scene" ? "SKILL.md" : `references/${name}.md`;
5
+ return readFile(new URL(`../skills/blender-scene/${relative}`, import.meta.url), "utf8");
6
+ }
@@ -0,0 +1,10 @@
1
+ import { z } from "zod";
2
+ export interface BlenderTool {
3
+ name: string;
4
+ title: string;
5
+ description: string;
6
+ schema: z.ZodObject;
7
+ code: (args: Record<string, unknown>) => string;
8
+ readOnly: boolean;
9
+ }
10
+ export declare const BL_TOOLS: BlenderTool[];
package/dist/tools.js ADDED
@@ -0,0 +1,270 @@
1
+ import { z } from "zod";
2
+ import * as schemas from "./schemas.js";
3
+ const readTools = new Set(["bl_get_scene_summary", "bl_get_object"]);
4
+ function scriptTool(name, title, description, shape, body) {
5
+ return {
6
+ name, title, description, schema: z.object(shape).strict(), readOnly: readTools.has(name),
7
+ code: args => name === "bl_execute" ? String(args.code) :
8
+ `import bpy, json, base64\n_args = json.loads(base64.b64decode("${Buffer.from(JSON.stringify(args)).toString("base64")}"))\n${body}\n`,
9
+ };
10
+ }
11
+ export const BL_TOOLS = [
12
+ scriptTool("bl_execute", "Execute Python (bpy)", "Run arbitrary Python inside the connected Blender with full `bpy` access. "
13
+ + "Assign a JSON-serialisable value to `result` to return data. This is the "
14
+ + "escape hatch — prefer a specific bl_* tool when one exists.", schemas.executeShape, ""),
15
+ scriptTool("bl_get_scene_summary", "Get Scene Summary", "Return a compact summary of the active scene: name, current frame, render "
16
+ + "engine, and every object's name/type/location.", schemas.sceneSummaryShape, `scene = bpy.context.scene
17
+ result = {
18
+ "scene": scene.name,
19
+ "frame_current": scene.frame_current,
20
+ "frame_range": [scene.frame_start, scene.frame_end],
21
+ "render_engine": scene.render.engine,
22
+ "objects": [
23
+ {"name": o.name, "type": o.type, "location": [round(c, 4) for c in o.location]}
24
+ for o in scene.objects
25
+ ],
26
+ }`),
27
+ scriptTool("bl_add_primitive", "Add Primitive Mesh", "Add a primitive mesh (cube, sphere, cylinder, cone, plane, torus, monkey) "
28
+ + "to the active scene at an optional world location (metres, Z up).", schemas.addPrimitiveShape, `_op = {
29
+ "cube": bpy.ops.mesh.primitive_cube_add,
30
+ "uv_sphere": bpy.ops.mesh.primitive_uv_sphere_add,
31
+ "ico_sphere": bpy.ops.mesh.primitive_ico_sphere_add,
32
+ "cylinder": bpy.ops.mesh.primitive_cylinder_add,
33
+ "cone": bpy.ops.mesh.primitive_cone_add,
34
+ "plane": bpy.ops.mesh.primitive_plane_add,
35
+ "torus": bpy.ops.mesh.primitive_torus_add,
36
+ "monkey": bpy.ops.mesh.primitive_monkey_add,
37
+ }[_args["kind"]]
38
+ _loc = tuple(_args.get("location") or (0.0, 0.0, 0.0))
39
+ _op(location=_loc)
40
+ _obj = bpy.context.active_object
41
+ if _args.get("name"):
42
+ _obj.name = _args["name"]
43
+ result = {"created": _obj.name, "type": _obj.type, "location": list(_obj.location)}`),
44
+ scriptTool("bl_delete_object", "Delete Object", "Delete an object from the scene by exact name.", schemas.deleteObjectShape, `_obj = bpy.data.objects.get(_args["name"])
45
+ if _obj is None:
46
+ raise ValueError("No object named %r" % _args["name"])
47
+ bpy.data.objects.remove(_obj, do_unlink=True)
48
+ result = {"deleted": _args["name"]}`),
49
+ scriptTool("bl_set_transform", "Set Transform", "Set any subset of an object's local location (scene units), rotation (radians, XYZ "
50
+ + "euler) and scale.", schemas.setTransformShape, `_obj = bpy.data.objects.get(_args["name"])
51
+ if _obj is None:
52
+ raise ValueError("No object named %r" % _args["name"])
53
+ if _args.get("location") is not None:
54
+ _obj.location = tuple(_args["location"])
55
+ if _args.get("rotation_euler") is not None:
56
+ _obj.rotation_euler = tuple(_args["rotation_euler"])
57
+ if _args.get("scale") is not None:
58
+ _obj.scale = tuple(_args["scale"])
59
+ result = {
60
+ "name": _obj.name,
61
+ "location": list(_obj.location),
62
+ "rotation_euler": list(_obj.rotation_euler),
63
+ "scale": list(_obj.scale),
64
+ }`),
65
+ scriptTool("bl_set_material", "Set Material", "Create and assign a Principled BSDF material (base colour RGBA 0-1, "
66
+ + "metallic, roughness) to an object.", schemas.setMaterialShape, `_obj = bpy.data.objects.get(_args["object"])
67
+ if _obj is None:
68
+ raise ValueError("No object named %r" % _args["object"])
69
+ if _obj.data is None or not hasattr(_obj.data, "materials"):
70
+ raise ValueError("This object type has no material slots")
71
+ _mat = bpy.data.materials.new(name=_args.get("material_name") or (_obj.name + "_mat"))
72
+ _mat.use_nodes = True
73
+ _bsdf = _mat.node_tree.nodes.get("Principled BSDF")
74
+ if _bsdf is not None:
75
+ if _args.get("color") is not None:
76
+ _bsdf.inputs["Base Color"].default_value = tuple(_args["color"])
77
+ if _args.get("metallic") is not None:
78
+ _bsdf.inputs["Metallic"].default_value = float(_args["metallic"])
79
+ if _args.get("roughness") is not None:
80
+ _bsdf.inputs["Roughness"].default_value = float(_args["roughness"])
81
+ if _obj.data is not None and hasattr(_obj.data, "materials"):
82
+ if _obj.data.materials:
83
+ _obj.data.materials[0] = _mat
84
+ else:
85
+ _obj.data.materials.append(_mat)
86
+ result = {"object": _obj.name, "material": _mat.name}`),
87
+ scriptTool("bl_import_model", "Import 3D Model", "Import an absolute local glb/gltf/fbx/obj file into the scene. Download external assets first with the client's file tools.", schemas.importModelShape, `import os
88
+ _path = _args["path"]
89
+ if not os.path.isabs(_path) or not os.path.isfile(_path):
90
+ raise ValueError("path must be an existing absolute file path")
91
+ _ext = os.path.splitext(_path)[1].lower()
92
+ _before = set(o.name for o in bpy.data.objects)
93
+ if _ext in (".glb", ".gltf"):
94
+ bpy.ops.import_scene.gltf(filepath=_path)
95
+ elif _ext == ".fbx":
96
+ bpy.ops.import_scene.fbx(filepath=_path)
97
+ elif _ext == ".obj":
98
+ bpy.ops.wm.obj_import(filepath=_path)
99
+ else:
100
+ raise ValueError("Unsupported model extension: %s" % _ext)
101
+ _new = [o for o in bpy.data.objects if o.name not in _before]
102
+ if _args.get("location") is not None and _new:
103
+ for _o in _new:
104
+ if _o.parent is None:
105
+ _o.location = tuple(_args["location"])
106
+ result = {"imported": [o.name for o in _new], "path": _path}`),
107
+ scriptTool("bl_add_light", "Add Light", "Add a POINT/SUN/SPOT/AREA light at a world position with an energy.", schemas.addLightShape, `_type = _args["type"]
108
+ _data = bpy.data.lights.new(name=_args.get("name") or (_type.title() + "Light"), type=_type)
109
+ _energy = _args.get("energy")
110
+ _data.energy = float(_energy) if _energy is not None else (5.0 if _type == "SUN" else 1000.0)
111
+ _obj = bpy.data.objects.new(name=_data.name, object_data=_data)
112
+ bpy.context.scene.collection.objects.link(_obj)
113
+ _obj.location = tuple(_args.get("location") or (0.0, 0.0, 5.0))
114
+ result = {"created": _obj.name, "type": _type, "energy": _data.energy}`),
115
+ scriptTool("bl_add_camera", "Add Camera", "Add a camera at a world position/rotation, optionally making it the "
116
+ + "active scene camera.", schemas.addCameraShape, `_data = bpy.data.cameras.new(name=_args.get("name") or "Camera")
117
+ _obj = bpy.data.objects.new(name=_data.name, object_data=_data)
118
+ bpy.context.scene.collection.objects.link(_obj)
119
+ _obj.location = tuple(_args.get("location") or (7.3589, -6.9258, 4.9583))
120
+ _obj.rotation_euler = tuple(_args.get("rotation_euler") or (1.1093, 0.0, 0.8149))
121
+ if _args.get("set_active", True):
122
+ bpy.context.scene.camera = _obj
123
+ result = {"created": _obj.name, "active": bpy.context.scene.camera == _obj}`),
124
+ scriptTool("bl_set_active_camera", "Set Active Camera", "Make an existing camera object the active scene camera.", schemas.setActiveCameraShape, `_obj = bpy.data.objects.get(_args["name"])
125
+ if _obj is None or _obj.type != "CAMERA":
126
+ raise ValueError("No camera named %r" % _args["name"])
127
+ bpy.context.scene.camera = _obj
128
+ result = {"active_camera": _obj.name}`),
129
+ scriptTool("bl_set_frame", "Set Current Frame", "Set the scene's current frame.", schemas.setFrameShape, `bpy.context.scene.frame_set(int(_args["frame"]))
130
+ result = {"frame_current": bpy.context.scene.frame_current}`),
131
+ scriptTool("bl_render", "Render Frame", "Render the current camera frame to PNG with optional temporary resolution, engine and sample overrides. Returns a local path and an inline preview when small enough.", schemas.renderShape, `import os, tempfile
132
+ _scene = bpy.context.scene
133
+ if _scene.camera is None:
134
+ raise ValueError("Set an active scene camera before rendering")
135
+ _out = _args.get("output_path")
136
+ if _out and (not os.path.isabs(_out) or not _out.lower().endswith(".png")):
137
+ raise ValueError("output_path must be an absolute .png path")
138
+ if _out and os.path.exists(_out) and not _args.get("overwrite", False):
139
+ raise ValueError("Output exists; choose another path or set overwrite=true")
140
+ if not _out:
141
+ _fd, _out = tempfile.mkstemp(suffix=".png", prefix="hf_render_")
142
+ os.close(_fd)
143
+ _old = (_scene.render.engine, _scene.render.resolution_x, _scene.render.resolution_y,
144
+ _scene.render.resolution_percentage, _scene.render.filepath, _scene.render.image_settings.file_format)
145
+ _old_samples = _scene.cycles.samples if hasattr(_scene, "cycles") else None
146
+ try:
147
+ if _args.get("engine"):
148
+ _scene.render.engine = _args["engine"]
149
+ if _args.get("resolution") is not None:
150
+ _scene.render.resolution_x, _scene.render.resolution_y = _args["resolution"]
151
+ _scene.render.resolution_percentage = 100
152
+ if _args.get("samples") is not None:
153
+ if _scene.render.engine != "CYCLES":
154
+ raise ValueError("samples override currently supports CYCLES only")
155
+ _scene.cycles.samples = _args["samples"]
156
+ _scene.render.filepath = _out
157
+ _scene.render.image_settings.file_format = "PNG"
158
+ bpy.ops.render.render(write_still=True)
159
+ _image = bpy.data.images.get("Render Result")
160
+ result = {"output_path": _out, "engine": _scene.render.engine,
161
+ "resolution": list(_image.size) if _image else None}
162
+ finally:
163
+ (_scene.render.engine, _scene.render.resolution_x, _scene.render.resolution_y,
164
+ _scene.render.resolution_percentage, _scene.render.filepath, _scene.render.image_settings.file_format) = _old
165
+ if _old_samples is not None:
166
+ _scene.cycles.samples = _old_samples`),
167
+ scriptTool("bl_screenshot", "Screenshot Viewport", "Capture what the 3D viewport currently shows (the agent's eyes) as an "
168
+ + "OpenGL viewport render, and return an inline PNG. Use this to SEE the scene "
169
+ + "before and after edits instead of guessing.", schemas.screenshotShape, `import base64, os, tempfile
170
+ _scene = bpy.context.scene
171
+ _orig_res = (_scene.render.resolution_x, _scene.render.resolution_y, _scene.render.resolution_percentage)
172
+ _orig_fp = _scene.render.filepath
173
+ _orig_fmt = _scene.render.image_settings.file_format
174
+ _cap = int(_args.get("max_size") or 1280)
175
+ _w, _h = _scene.render.resolution_x, _scene.render.resolution_y
176
+ _longest = max(_w, _h) or _cap
177
+ _scale = min(1.0, _cap / _longest)
178
+ _area = None
179
+ _win = None
180
+ for _wnd in bpy.context.window_manager.windows:
181
+ for _a in _wnd.screen.areas:
182
+ if _a.type == "VIEW_3D":
183
+ _area, _win = _a, _wnd
184
+ break
185
+ if _area:
186
+ break
187
+ _shading_prev = None
188
+ if _args.get("shading") and _area is not None:
189
+ _sp = _area.spaces.active
190
+ _shading_prev = _sp.shading.type
191
+ _sp.shading.type = _args["shading"]
192
+ _fd, _path = tempfile.mkstemp(suffix=".png", prefix="hf_shot_")
193
+ os.close(_fd)
194
+ try:
195
+ _scene.render.resolution_x = max(1, int(_w * _scale))
196
+ _scene.render.resolution_y = max(1, int(_h * _scale))
197
+ _scene.render.resolution_percentage = 100
198
+ _scene.render.filepath = _path
199
+ _scene.render.image_settings.file_format = "PNG"
200
+ if _area is not None:
201
+ _region = next((_r for _r in _area.regions if _r.type == "WINDOW"), None)
202
+ with bpy.context.temp_override(window=_win, area=_area, region=_region):
203
+ bpy.ops.render.opengl(write_still=True)
204
+ else:
205
+ raise RuntimeError("No VIEW_3D area is available; use bl_render with a scene camera")
206
+ with open(_path, "rb") as _fh:
207
+ _raw = _fh.read()
208
+ result = {"pngBase64": base64.b64encode(_raw).decode("ascii"),
209
+ "width": _scene.render.resolution_x, "height": _scene.render.resolution_y}
210
+ finally:
211
+ _scene.render.resolution_x, _scene.render.resolution_y, _scene.render.resolution_percentage = _orig_res
212
+ _scene.render.filepath = _orig_fp
213
+ _scene.render.image_settings.file_format = _orig_fmt
214
+ if _shading_prev is not None and _area is not None:
215
+ _area.spaces.active.shading.type = _shading_prev
216
+ try:
217
+ os.remove(_path)
218
+ except OSError:
219
+ pass`),
220
+ scriptTool("bl_get_object", "Get Object Detail", "Detailed inspection of one object: transform, dimensions, bounding box, "
221
+ + "material slots, modifiers, parent, and mesh stats (verts/edges/faces).", schemas.getObjectShape, `_o = bpy.data.objects.get(_args["name"])
222
+ if _o is None:
223
+ raise ValueError("No object named %r" % _args["name"])
224
+ _info = {
225
+ "name": _o.name,
226
+ "type": _o.type,
227
+ "location": [round(c, 4) for c in _o.location],
228
+ "rotation_euler": [round(c, 4) for c in _o.rotation_euler],
229
+ "scale": [round(c, 4) for c in _o.scale],
230
+ "dimensions": [round(c, 4) for c in _o.dimensions],
231
+ "visible": not _o.hide_get(),
232
+ "parent": _o.parent.name if _o.parent else None,
233
+ "collections": [c.name for c in _o.users_collection],
234
+ "modifiers": [{"name": m.name, "type": m.type} for m in _o.modifiers],
235
+ "material_slots": [s.material.name if s.material else None for s in _o.material_slots],
236
+ }
237
+ if _o.type == "MESH" and _o.data is not None:
238
+ _info["mesh"] = {"vertices": len(_o.data.vertices),
239
+ "edges": len(_o.data.edges),
240
+ "polygons": len(_o.data.polygons)}
241
+ result = _info`),
242
+ ];
243
+ BL_TOOLS.push(scriptTool("bl_health", "Inspect Blender", "Read the live Blender version, process, active file and scene; proves main-thread execution.", {}, `import os
244
+ result = {"version": bpy.app.version_string, "pid": os.getpid(), "background": bpy.app.background,
245
+ "file": bpy.data.filepath, "dirty": bpy.data.is_dirty, "scene": bpy.context.scene.name}`), scriptTool("bl_save_project", "Save Blender Project", "Save the current scene to an absolute .blend path. Existing files require overwrite=true.", {
246
+ path: z.string().min(1), overwrite: z.boolean().default(false),
247
+ }, `import os
248
+ _path = _args["path"]
249
+ if not os.path.isabs(_path) or not _path.lower().endswith(".blend"):
250
+ raise ValueError("Provide an absolute .blend path")
251
+ if os.path.exists(_path) and not _args["overwrite"]:
252
+ raise ValueError("File exists; use a new path or explicitly set overwrite=true")
253
+ bpy.ops.wm.save_as_mainfile(filepath=_path, check_existing=False)
254
+ result = {"path": bpy.data.filepath, "saved": not bpy.data.is_dirty}`), scriptTool("bl_open_project", "Open Blender Project", "Replace the active project with an existing .blend file. Refuses unsaved changes unless discard_unsaved=true.", {
255
+ path: z.string().min(1), discard_unsaved: z.boolean().default(false),
256
+ }, `import os
257
+ _path = _args["path"]
258
+ if not os.path.isabs(_path) or not os.path.isfile(_path) or not _path.lower().endswith(".blend"):
259
+ raise ValueError("Provide an existing absolute .blend path")
260
+ if bpy.data.is_dirty and not _args["discard_unsaved"]:
261
+ raise ValueError("The current project has unsaved changes; save first or explicitly set discard_unsaved=true")
262
+ bpy.ops.wm.open_mainfile(filepath=_path)
263
+ result = {"path": bpy.data.filepath, "scene": bpy.context.scene.name}`), scriptTool("bl_insert_keyframe", "Insert Object Keyframe", "Key the current location, Euler rotation or scale of a named object at a frame.", {
264
+ name: z.string().min(1), property: z.enum(["location", "rotation_euler", "scale"]), frame: z.number().int(),
265
+ }, `_obj = bpy.data.objects.get(_args["name"])
266
+ if _obj is None:
267
+ raise ValueError("Object not found")
268
+ _ok = _obj.keyframe_insert(data_path=_args["property"], frame=_args["frame"])
269
+ result = {"inserted": _ok, "object": _obj.name, "property": _args["property"], "frame": _args["frame"]}`));
270
+ BL_TOOLS.find(tool => tool.name === "bl_health").readOnly = true;
@@ -0,0 +1,31 @@
1
+ import { z } from "zod";
2
+ export declare const runtimeDirectory: () => string;
3
+ declare const connectionSchema: z.ZodObject<{
4
+ protocol: z.ZodLiteral<"higgsfield-blender/1">;
5
+ pid: z.ZodNumber;
6
+ port: z.ZodNumber;
7
+ token: z.ZodString;
8
+ }, z.core.$strip>;
9
+ export type Connection = z.infer<typeof connectionSchema>;
10
+ declare const jobSchema: z.ZodObject<{
11
+ job_id: z.ZodString;
12
+ state: z.ZodEnum<{
13
+ queued: "queued";
14
+ running: "running";
15
+ completed: "completed";
16
+ expired: "expired";
17
+ }>;
18
+ ok: z.ZodOptional<z.ZodBoolean>;
19
+ result: z.ZodOptional<z.ZodUnknown>;
20
+ error: z.ZodOptional<z.ZodString>;
21
+ stdout: z.ZodOptional<z.ZodString>;
22
+ stderr: z.ZodOptional<z.ZodString>;
23
+ }, z.core.$strip>;
24
+ export type JobResult = z.infer<typeof jobSchema>;
25
+ export declare function discover(directory?: string): Promise<Connection[]>;
26
+ export declare class BlenderTransport {
27
+ connection(): Promise<Connection>;
28
+ status(jobId: string, connection?: Connection): Promise<JobResult>;
29
+ execute(code: string, timeoutSeconds?: number): Promise<JobResult>;
30
+ }
31
+ export {};
@@ -0,0 +1,81 @@
1
+ import { readdir, readFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { setTimeout as delay } from "node:timers/promises";
5
+ import { z } from "zod";
6
+ export const runtimeDirectory = () => process.env.BLENDER_MCP_RUNTIME_DIR || join(homedir(), ".higgsfield", "blender");
7
+ const connectionSchema = z.object({
8
+ protocol: z.literal("higgsfield-blender/1"), pid: z.number().int().positive(),
9
+ port: z.number().int().min(1).max(65535), token: z.string().regex(/^[a-f0-9]{64}$/),
10
+ });
11
+ const jobSchema = z.object({
12
+ job_id: z.string(), state: z.enum(["queued", "running", "completed", "expired"]),
13
+ ok: z.boolean().optional(), result: z.unknown().optional(), error: z.string().optional(),
14
+ stdout: z.string().optional(), stderr: z.string().optional(),
15
+ });
16
+ async function request(connection, path, body) {
17
+ const response = await fetch(`http://127.0.0.1:${connection.port}${path}`, {
18
+ method: body === undefined ? "GET" : "POST", redirect: "error",
19
+ headers: { Authorization: `Bearer ${connection.token}`, "Content-Type": "application/json" },
20
+ body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(5000),
21
+ });
22
+ if (!response.ok)
23
+ throw new Error(`Blender bridge HTTP ${response.status}: ${(await response.text()).slice(0, 1000)}`);
24
+ return response.json();
25
+ }
26
+ export async function discover(directory = runtimeDirectory()) {
27
+ const names = await readdir(directory).catch((error) => {
28
+ if (error.code === "ENOENT")
29
+ return [];
30
+ throw error;
31
+ });
32
+ const candidates = await Promise.all(names.filter(name => /^bridge-\d+\.json$/.test(name)).map(async (name) => {
33
+ try {
34
+ const connection = connectionSchema.parse(JSON.parse(await readFile(join(directory, name), "utf8")));
35
+ const health = z.object({ protocol: z.literal("higgsfield-blender/1"), pid: z.number() }).parse(await request(connection, "/health"));
36
+ return health.pid === connection.pid ? connection : undefined;
37
+ }
38
+ catch {
39
+ return undefined;
40
+ }
41
+ }));
42
+ return candidates.filter((value) => value !== undefined);
43
+ }
44
+ export class BlenderTransport {
45
+ async connection() {
46
+ const connections = await discover();
47
+ const selected = process.env.BLENDER_MCP_PID;
48
+ const matches = selected ? connections.filter(item => String(item.pid) === selected) : connections;
49
+ if (!matches.length)
50
+ throw new Error("No live Blender bridge found. Open Blender with the Higgsfield use Blender add-on enabled; run fnf-blender doctor on this computer.");
51
+ if (matches.length > 1)
52
+ throw new Error(`Multiple Blender instances: ${matches.map(item => item.pid).join(", ")}. Set BLENDER_MCP_PID to choose one before editing.`);
53
+ return matches[0];
54
+ }
55
+ async status(jobId, connection) {
56
+ return jobSchema.parse(await request(connection ?? await this.connection(), `/jobs/${encodeURIComponent(jobId)}`));
57
+ }
58
+ async execute(code, timeoutSeconds = 120) {
59
+ const connection = await this.connection();
60
+ let accepted;
61
+ try {
62
+ accepted = jobSchema.parse(await request(connection, "/execute", { code, timeout_seconds: timeoutSeconds }));
63
+ }
64
+ catch (error) {
65
+ throw new Error(`Submission failed; execution may have been accepted. Inspect scene state before retrying. ${String(error)}`);
66
+ }
67
+ const deadline = Date.now() + timeoutSeconds * 1000;
68
+ try {
69
+ while (Date.now() < deadline) {
70
+ const result = await this.status(accepted.job_id, connection);
71
+ if (result.state === "completed" || result.state === "expired")
72
+ return result;
73
+ await delay(100);
74
+ }
75
+ }
76
+ catch (error) {
77
+ throw new Error(`Lost contact with Blender; job_id=${accepted.job_id}, pid=${connection.pid}. Use bl_job_status before retrying. ${String(error)}`);
78
+ }
79
+ throw new Error(`Blender is still busy; job_id=${accepted.job_id}, pid=${connection.pid}. Execution may continue. Use bl_job_status before retrying; no automatic retry was sent.`);
80
+ }
81
+ }
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "fnf-blender-mcp",
3
+ "version": "0.1.0",
4
+ "description": "Higgsfield use Blender: local stdio MCP and authenticated loopback Blender add-on.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "engines": {
8
+ "node": ">=24"
9
+ },
10
+ "bin": {
11
+ "fnf-blender": "dist/cli.js",
12
+ "fnf-blender-mcp": "dist/index.js"
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "addon/**/*.py",
17
+ "skills",
18
+ "README.md",
19
+ "UPSTREAM.md",
20
+ "LICENSE",
21
+ "scripts/*.py"
22
+ ],
23
+ "scripts": {
24
+ "build": "tsc",
25
+ "start": "node dist/index.js",
26
+ "typecheck": "tsc --noEmit",
27
+ "test": "npm run build && node --test tests/*.test.mjs && python3 -m unittest discover -s tests -p \"test_*.py\"",
28
+ "prepack": "npm run build",
29
+ "test:package": "node scripts/verify-package.mjs"
30
+ },
31
+ "dependencies": {
32
+ "@modelcontextprotocol/server": "^2.0.0",
33
+ "zod": "^4.3.6"
34
+ },
35
+ "devDependencies": {
36
+ "@modelcontextprotocol/client": "^2.0.0",
37
+ "@types/node": "^25.5.2",
38
+ "typescript": "^6.0.2"
39
+ },
40
+ "publishConfig": {
41
+ "access": "public",
42
+ "registry": "https://registry.npmjs.org/"
43
+ },
44
+ "homepage": "https://www.npmjs.com/package/fnf-blender-mcp",
45
+ "keywords": [
46
+ "blender",
47
+ "mcp",
48
+ "higgsfield",
49
+ "3d",
50
+ "bpy"
51
+ ]
52
+ }
@@ -0,0 +1,18 @@
1
+ """Install source files only; never reset or save the user's preferences."""
2
+ import shutil
3
+ from pathlib import Path
4
+ import bpy
5
+
6
+ if bpy.app.version < (4, 2, 0):
7
+ raise RuntimeError("Higgsfield use Blender requires Blender 4.2+")
8
+ source = Path(__file__).resolve().parent.parent / "addon" / "higgsfield_use_blender"
9
+ target = Path(bpy.utils.user_resource("SCRIPTS", path="addons", create=True)) / source.name
10
+ marker = target / ".higgsfield-use-blender"
11
+ if target.exists() and not marker.is_file():
12
+ raise RuntimeError("Refusing to overwrite an unrecognized add-on directory: %s" % target)
13
+ target.mkdir(parents=True, exist_ok=True)
14
+ for name in ("__init__.py", "bridge.py"):
15
+ shutil.copy2(source / name, target / name)
16
+ marker.write_text("fnf-blender-mcp\n")
17
+ print("Installed: %s" % target)
18
+ print("Enable Higgsfield use Blender in Preferences > Add-ons, then save preferences for automatic startup.")
@@ -0,0 +1,10 @@
1
+ """Load the bundled bridge in a new Blender session without changing preferences."""
2
+ import sys
3
+ from pathlib import Path
4
+ import bpy
5
+
6
+ if bpy.app.version < (4, 2, 0):
7
+ raise RuntimeError("Higgsfield use Blender requires Blender 4.2+")
8
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "addon"))
9
+ import higgsfield_use_blender
10
+ higgsfield_use_blender.register()
@@ -0,0 +1,22 @@
1
+ ---
2
+ name: blender-scene
3
+ description: Create, inspect, edit and render native scenes through the local Higgsfield use Blender MCP.
4
+ ---
5
+
6
+ # Local Blender workflow
7
+
8
+ Call `bl_health` and `bl_get_scene_summary` before editing. Inspect important objects with `bl_get_object`. If several instances are open, select the intended PID in the server environment before mutation.
9
+
10
+ Prefer the typed tools listed by this server. Use `bl_execute` for other bpy operations; assign a JSON-compatible value to `result`. Returned stdout/stderr are capped at 64 KiB each. Do not start threads that touch bpy. Use data APIs where possible; operators depend on mode, selection and UI context.
11
+
12
+ Distances are in scene units (normally metres); Z is up and Euler angles are radians. Check the scene's unit settings when scale matters. Names identify objects exactly; inspect suffixes rather than assuming them.
13
+
14
+ Read topic guidance through `bl_get_skill`: `modeling`, `materials`, `lighting-camera`, or `animation`, as needed.
15
+
16
+ Preserve unrelated objects. Save `.blend` deliverables requested by the user with `bl_save_project`; existing files need `overwrite=true`. Before replacing the active project, save needed changes. `bl_open_project` refuses unsaved changes unless explicitly discarded.
17
+
18
+ A script may partially mutate before failing. Inspect current state before retrying. A timed-out command may still be running; use its `job_id` with `bl_job_status` on the same Blender PID. Commands are not automatically retried or rolled back. Job history is in-memory and bounded; restarting Blender loses it.
19
+
20
+ Verify structure with fresh reads and appearance by viewing `bl_screenshot` or `bl_render`. Screenshots require a VIEW_3D area; headless renders need a scene camera. Rendering can block Blender while it runs. Set the MCP client's tool timeout above 300 seconds for long renders.
21
+
22
+ This package has no cloud generation tools or bundled bpy API/manual search. Use a separately connected Higgsfield service for generated assets; download results then call `bl_import_model` with an absolute local path. Read official version-matched Blender docs for unfamiliar APIs.
@@ -0,0 +1,5 @@
1
+ # Animation
2
+
3
+ Establish duration, frame rate, start/end state and intended motion before adding keys. Inspect existing animation to preserve curves outside the requested range. `bl_set_frame`, `bl_set_transform`, and `bl_insert_keyframe` cover basic object motion; use bpy for constraints, rigs, custom properties or interpolation.
4
+
5
+ Check start, middle and end frames plus extrema. Verify contact, camera framing, loop seams and unwanted Euler flips. Blender animation API details vary by version; inspect live version and API before addressing actions, slots or channel bags. Save the native .blend when requested and render actual frames before claiming visual completion.
@@ -0,0 +1,5 @@
1
+ # Lighting and camera
2
+
3
+ Inspect the active camera, focal length and framing before changes. Use `bl_add_camera` and `bl_set_active_camera`; angles are radians. For a look-at orientation, derive rotation from target minus camera location with `to_track_quat('-Z', 'Y')`.
4
+
5
+ Choose a key, fill and separation only where they improve the requested image. Light size controls softness; exposure and material roughness also affect highlight appearance. Test at low resolution and sample count with `bl_render`, view the image, then raise quality. A viewport capture is not evidence of the final camera render.
@@ -0,0 +1,5 @@
1
+ # Materials
2
+
3
+ Inspect existing material slots and shared datablocks before changing them. `bl_set_material` creates a new Principled material and replaces the first slot; use `bl_execute` when editing an existing shader graph or preserving several slots. Blender colors may use linear scene values; do not treat screenshot RGB values as automatically equivalent.
4
+
5
+ Keep texture color spaces appropriate: color textures normally sRGB, roughness/metallic/normal/displacement Non-Color. Verify UV coverage and texture scale. Judge materials using the final render engine and lighting, including both highlight and shadow response.
@@ -0,0 +1,5 @@
1
+ # Modeling
2
+
3
+ Inspect object types, dimensions, transforms and parent relationships before editing. Keep meaningful object names and editable modifiers. Work in object mode for object-level operators; restore selection when it matters. Apply scale only when a downstream operation needs it. Duplicate or save a recovery copy before broad destructive changes within the user's scope.
4
+
5
+ Use `bl_add_primitive`, `bl_set_transform`, and `bl_get_object` for focused edits. For topology operations use `bl_execute` with explicit context and validate mesh counts, bounds, normals and silhouette afterward. Never clear the default scene as an incidental setup step in an existing project.
@@ -0,0 +1,18 @@
1
+ ---
2
+ name: use-blender
3
+ description: Install and connect the local Higgsfield use Blender MCP, or use it to edit native Blender scenes.
4
+ ---
5
+
6
+ # Higgsfield use Blender
7
+
8
+ Connect a desktop MCP client to Blender on the same computer:
9
+
10
+ Desktop client → stdio → local fnf-blender-mcp → authenticated localhost HTTP → Blender Python add-on.
11
+
12
+ Use **Higgsfield use Blender** as the display name and `higgsfield-use-blender` as the server identifier. No WebSocket, cloud bridge login or Higgsfield account is needed for local scene operations. Cloud asset generation uses a separate service.
13
+
14
+ The runtime is distributed as the public npm package `fnf-blender-mcp`; installation requires no source checkout or build.
15
+
16
+ This is a local setup command, not a generation preset. A cloud sandbox cannot install into the user's desktop Blender. First discover available local `bl_*` tools and check an existing connection before reinstalling.
17
+
18
+ For setup, read [installation](references/installation.md). For connection diagnosis and edits, read [verification](references/verification.md). When these files are served by get_preset_instructions, load them with `/use-blender/references/installation` and `/use-blender/references/verification` respectively.