fnf-blender-mcp 0.2.0 → 0.2.2
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/README.md +18 -27
- package/dist/server.js +40 -6
- package/dist/skills.d.ts +15 -2
- package/dist/skills.js +89 -5
- package/dist/tools.js +8 -4
- package/dist/transport.d.ts +1 -1
- package/dist/transport.js +2 -2
- package/package.json +3 -13
- package/scripts/background_runner.py +8 -1
- package/skills/blender-animation/SKILL.md +140 -0
- package/skills/blender-audit-finalize/SKILL.md +139 -0
- package/skills/blender-camera-blocking/SKILL.md +32 -0
- package/skills/blender-camera-blocking/assets/Blockstage-0.3.0.zip +0 -0
- package/skills/blender-camera-blocking/references/addon-installation.md +17 -0
- package/skills/blender-camera-blocking/references/character-staging.md +19 -0
- package/skills/blender-camera-blocking/references/layout-and-assets.md +19 -0
- package/skills/blender-camera-blocking/references/live-construction.md +7 -0
- package/skills/blender-camera-led-assembly/SKILL.md +48 -0
- package/skills/blender-camera-led-assembly/references/blender-mechanics.md +45 -0
- package/skills/blender-camera-led-assembly/references/camera-choreography.md +25 -0
- package/skills/blender-camera-led-assembly/references/case-template.md +23 -0
- package/skills/blender-camera-led-assembly/references/environment-assembly.md +27 -0
- package/skills/blender-camera-led-assembly/references/product-assembly.md +25 -0
- package/skills/blender-camera-led-assembly/references/validation-delivery.md +33 -0
- package/skills/blender-destruction/SKILL.md +24 -0
- package/skills/blender-destruction/references/assembly.md +23 -0
- package/skills/blender-destruction/references/delivery.md +29 -0
- package/skills/blender-destruction/references/simulation.md +25 -0
- package/skills/blender-generation/SKILL.md +139 -0
- package/skills/blender-greybox/SKILL.md +103 -0
- package/skills/blender-hdri/SKILL.md +133 -0
- package/skills/blender-lighting-camera/SKILL.md +217 -0
- package/skills/blender-lookdev/SKILL.md +99 -0
- package/skills/blender-modeling/SKILL.md +181 -0
- package/skills/blender-pbr/SKILL.md +155 -0
- package/skills/blender-scene/SKILL.md +66 -1
- package/skills/blender-scene-spec/SKILL.md +177 -0
- package/skills/blender-stylized-materials/SKILL.md +34 -0
- package/skills/blender-stylized-materials/assets/BUNDLE.md +19 -0
- package/skills/blender-stylized-materials/assets/cube-demo.blend +0 -0
- package/skills/blender-stylized-materials/assets/cube-preview.png +0 -0
- package/skills/blender-stylized-materials/assets/materials.blend +0 -0
- package/skills/blender-stylized-materials/assets/previews/BACKGROUND.png +0 -0
- package/skills/blender-stylized-materials/assets/previews/CANVAS.png +0 -0
- package/skills/blender-stylized-materials/assets/previews/CEL.png +0 -0
- package/skills/blender-stylized-materials/assets/previews/COMIC_PULSE.png +0 -0
- package/skills/blender-stylized-materials/assets/previews/DRYBRUSH.png +0 -0
- package/skills/blender-stylized-materials/assets/previews/HALFTONE.png +0 -0
- package/skills/blender-stylized-materials/assets/previews/MANGA.png +0 -0
- package/skills/blender-stylized-materials/assets/previews/METAL.png +0 -0
- package/skills/blender-stylized-materials/assets/previews/NEON.png +0 -0
- package/skills/blender-stylized-materials/assets/previews/PAINT.png +0 -0
- package/skills/blender-stylized-materials/assets/previews/WATERCOLOR.png +0 -0
- package/skills/blender-stylized-materials/assets/stylized-materials-addon.zip +0 -0
- package/skills/blender-stylized-materials/references/material-engineering.md +31 -0
- package/skills/blender-stylized-materials/references/ready-to-use-bundle.md +23 -0
- package/skills/blender-stylized-materials/references/scene-delivery.md +27 -0
- package/skills/blender-stylized-materials/references/visual-library.md +21 -0
- package/skills/blender-volatile/SKILL.md +150 -0
- package/skills/use-blender/SKILL.md +2 -1
- package/skills/use-blender/references/installation.md +5 -5
- package/RELEASING.md +0 -11
package/README.md
CHANGED
|
@@ -1,27 +1,29 @@
|
|
|
1
|
-
# Higgsfield
|
|
1
|
+
# Higgsfield Blender MCP
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Local MCP server that controls a dedicated background Blender process. No add-on, HTTP listener, WebSocket, cloud account or open Blender window is required.
|
|
4
4
|
|
|
5
5
|
```text
|
|
6
6
|
Desktop MCP client → stdio → Node MCP → process pipes → Blender --background → bpy
|
|
7
7
|
```
|
|
8
8
|
|
|
9
|
-
Each MCP connection owns one process. Its scene persists between commands; it cannot access unsaved work in an already-open desktop Blender. Open saved input with `bl_open_project`, edit and render, then save a `.blend` output to inspect in the desktop UI. Disconnecting loses unsaved session changes.
|
|
9
|
+
Each MCP connection owns one process. Its scene persists between commands; it cannot access unsaved work in an already-open desktop Blender. Open saved input with `bl_open_project`, edit and render, then save a `.blend` output to inspect in the desktop UI. Disconnecting loses unsaved session changes.
|
|
10
10
|
|
|
11
|
-
##
|
|
11
|
+
## Requirements
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
- Node.js 24 or later with npm.
|
|
14
|
+
- Blender 4.2 or later on the same computer as the MCP client.
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
14
17
|
|
|
15
18
|
```sh
|
|
16
|
-
npm view fnf-blender-mcp@0.2.0 version --registry=https://registry.npmjs.org/
|
|
17
19
|
blenderDir="$HOME/.higgsfield/blender-mcp"
|
|
18
|
-
npm install --prefix "$blenderDir"
|
|
20
|
+
npm install --prefix "$blenderDir" fnf-blender-mcp@latest
|
|
19
21
|
blenderCli="$blenderDir/node_modules/fnf-blender-mcp/dist/cli.js"
|
|
20
22
|
node "$blenderCli" doctor --blender "/absolute/path/to/blender"
|
|
21
23
|
node "$blenderCli" config --blender "/absolute/path/to/blender" --format json
|
|
22
24
|
```
|
|
23
25
|
|
|
24
|
-
Use the executable inside `Blender.app/Contents/MacOS/Blender` on macOS, or `blender.exe` on Windows. `config --format toml` prints a Codex entry. Both formats include absolute Node/server paths and `BLENDER_EXECUTABLE`; merge the entry into the intended client and refresh its connection. The helper prints configuration but does not register a server.
|
|
26
|
+
Use the executable inside `Blender.app/Contents/MacOS/Blender` on macOS, or `blender.exe` on Windows. `config --format toml` prints a Codex entry. Both formats include absolute Node/server paths and `BLENDER_EXECUTABLE`; merge the entry into the intended client and refresh its connection. The helper prints configuration but does not register a server. The installed package includes a `use-blender` skill with additional setup guidance.
|
|
25
27
|
|
|
26
28
|
The server starts Blender on its first execution call. `doctor` checks a separate temporary background process and terminates it afterward. Verify conversation access with `bl_health` and `bl_get_scene_summary`. `BLENDER_EXECUTABLE` can be set directly; without it the runtime tries standard installation locations and PATH. No separate Python installation is needed at runtime.
|
|
27
29
|
|
|
@@ -36,33 +38,22 @@ The server starts Blender on its first execution call. `doctor` checks a separat
|
|
|
36
38
|
| Files | `bl_save_project`, `bl_open_project` |
|
|
37
39
|
| Evidence | `bl_render` |
|
|
38
40
|
| Python/status | `bl_execute`, `bl_job_status` |
|
|
39
|
-
| Offline guidance | `bl_get_skill` |
|
|
41
|
+
| Offline guidance | `bl_list_skills`, `bl_get_skill`, `bl_get_skill_asset` |
|
|
40
42
|
|
|
41
43
|
Start with `bl_get_skill(name: "blender-scene")`. Python runs sequentially on the process's main thread. Scene datablocks persist, while script-local variables do not. Assign a JSON-compatible `result` to return data. Python stdout/stderr are capped at 64 Ki characters each; JSON results are limited to 4 MiB. Native Blender diagnostics do not enter the MCP stdout protocol.
|
|
42
44
|
|
|
45
|
+
The npm archive contains the Blender craft modules. `bl_list_skills` lists the modules in the installed package and their references/assets; `bl_get_skill` reads an entry or reference, and `bl_get_skill_asset` returns the local path of a bundled file. The original five `bl_get_skill` names remain valid. An MCP client can read this guidance directly without installing the modules as client-side skills; client-side installation is optional for hosts that support automatic skill activation. Existing installations do not gain new modules or tools until the npm package is updated and the MCP connection refreshed. Skill guidance does not execute Blender operations or prove a live connection.
|
|
46
|
+
|
|
43
47
|
Render a camera frame to PNG; files up to 4 MiB receive an inline preview. Cycles supports sample overrides. Save/render require `overwrite: true` for existing output files; opening a different project refuses unsaved changes unless explicitly discarded. Arbitrary Python is not sandboxed and can bypass these typed-tool guards. Automatic execution of Python embedded in opened `.blend` files is disabled.
|
|
44
48
|
|
|
45
|
-
##
|
|
49
|
+
## Troubleshooting and limits
|
|
46
50
|
|
|
47
51
|
A timed-out job continues and keeps its `job_id`. Query `bl_job_status` before retrying; additional execution commands are rejected while it runs. No automatic retries or rollbacks occur. Status retains at most 128 jobs within the MCP process. If Blender crashes, the session fails and is not restarted automatically: inspect output files before reconnecting to a new empty session. Closing the MCP terminates its child process, including a running job; save needed changes first.
|
|
48
52
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
## Development and validation
|
|
52
|
-
|
|
53
|
-
From the repository root:
|
|
54
|
-
|
|
55
|
-
```sh
|
|
56
|
-
cd blender
|
|
57
|
-
npm ci
|
|
58
|
-
npm test
|
|
59
|
-
npm run typecheck
|
|
60
|
-
npm run test:package
|
|
61
|
-
BLENDER_EXECUTABLE=/absolute/path/to/blender npm run test:live
|
|
62
|
-
```
|
|
63
|
-
|
|
64
|
-
Offline tests exercise actual MCP and child-process pipes using a Python CLI fixture, persistent session state, isolation, timeout recovery, process death, bounded results/history, output formatting, CLI configuration and typed-script guards. They do not replace live Blender validation. `test:package` installs the archive independently and verifies 19 tools, offline guidance and the absence of add-on files. `test:live` uses real Blender for geometry, materials, keyframes, file guards and a small Cycles render in disposable files.
|
|
53
|
+
Use camera renders for visual evidence. No existing desktop scenes, preferences or installed add-ons are modified during startup. Multiple MCP connections have separate background scenes.
|
|
65
54
|
|
|
66
55
|
Verified on macOS arm64 with Blender 4.2.23 LTS: persistent scene, mesh/material/camera/light edits, keyframes, save/reopen guards and a Cycles PNG preview. Windows/Linux native Blender execution has not been verified locally.
|
|
67
56
|
|
|
68
|
-
|
|
57
|
+
The installed package includes `UPSTREAM.md` and `LICENSE` for provenance and licensing details.
|
|
58
|
+
|
|
59
|
+
Background Blender does not reliably mark direct Python edits as dirty. The MCP also tracks attempted mutations, including failed commands and arbitrary `bl_execute` calls, conservatively as unsaved until `bl_save_project` or an explicitly permitted `bl_open_project` succeeds. Read tools preserve this state.
|
package/dist/server.js
CHANGED
|
@@ -3,7 +3,7 @@ import { McpServer } from "@modelcontextprotocol/server";
|
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { BL_TOOLS } from "./tools.js";
|
|
5
5
|
import { BlenderTransport } from "./transport.js";
|
|
6
|
-
import { getSkill,
|
|
6
|
+
import { getSkill, getSkillAsset, listSkills } from "./skills.js";
|
|
7
7
|
const packageInfo = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8"));
|
|
8
8
|
const errorResult = (error) => ({ isError: true, content: [{ type: "text", text: String(error) }] });
|
|
9
9
|
export async function formatResult(job) {
|
|
@@ -32,7 +32,7 @@ export async function formatResult(job) {
|
|
|
32
32
|
}
|
|
33
33
|
export function createServer(transport = new BlenderTransport()) {
|
|
34
34
|
const server = new McpServer({ name: "higgsfield-use-blender", title: "Higgsfield use Blender", version: packageInfo.version }, {
|
|
35
|
-
instructions: "Control a dedicated background Blender process; it cannot access an open desktop window. Save .blend files before disconnecting; unsaved state is lost on reconnect. 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.",
|
|
35
|
+
instructions: "Control a dedicated background Blender process; it cannot access an open desktop window. Save .blend files before disconnecting; unsaved state is lost on reconnect. Read bl_get_skill(blender-scene), then bl_health and bl_get_scene_summary before edits. For specialized work, call bl_list_skills, load the relevant skill with bl_get_skill and only needed references; bl_get_skill_asset locates bundled assets. 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
36
|
});
|
|
37
37
|
for (const tool of BL_TOOLS) {
|
|
38
38
|
server.registerTool(tool.name, {
|
|
@@ -40,7 +40,8 @@ export function createServer(transport = new BlenderTransport()) {
|
|
|
40
40
|
annotations: { readOnlyHint: tool.readOnly, destructiveHint: !tool.readOnly, idempotentHint: tool.readOnly, openWorldHint: tool.name === "bl_execute" },
|
|
41
41
|
}, async (args) => {
|
|
42
42
|
try {
|
|
43
|
-
|
|
43
|
+
const mutates = !tool.readOnly && !["bl_save_project", "bl_open_project"].includes(tool.name);
|
|
44
|
+
return await formatResult(await transport.execute(tool.code(args), tool.name === "bl_render" ? 300 : 120, mutates));
|
|
44
45
|
}
|
|
45
46
|
catch (error) {
|
|
46
47
|
return errorResult(error);
|
|
@@ -59,10 +60,43 @@ export function createServer(transport = new BlenderTransport()) {
|
|
|
59
60
|
return errorResult(error);
|
|
60
61
|
}
|
|
61
62
|
});
|
|
63
|
+
server.registerTool("bl_list_skills", {
|
|
64
|
+
title: "List Blender Skills", description: "List bundled Blender skill names, descriptions, references and assets. Does not require Blender to be running.",
|
|
65
|
+
inputSchema: z.object({}).strict(),
|
|
66
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
67
|
+
}, async () => {
|
|
68
|
+
try {
|
|
69
|
+
const index = await listSkills();
|
|
70
|
+
return { content: [{ type: "text", text: JSON.stringify(index) }], structuredContent: index };
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
return errorResult(error);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
62
76
|
server.registerTool("bl_get_skill", {
|
|
63
|
-
title: "Read Blender Skill", description: "Load
|
|
64
|
-
inputSchema: z.object({ name: z.
|
|
77
|
+
title: "Read Blender Skill", description: "Load a bundled Blender skill or one of its listed references. Start with blender-scene. Legacy topic names remain supported. Does not require Blender to be running.",
|
|
78
|
+
inputSchema: z.object({ name: z.string().default("blender-scene"), document: z.string().default("SKILL.md") }).strict(),
|
|
79
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
80
|
+
}, async ({ name, document }) => {
|
|
81
|
+
try {
|
|
82
|
+
return { content: [{ type: "text", text: await getSkill(name, document) }] };
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
return errorResult(error);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
server.registerTool("bl_get_skill_asset", {
|
|
89
|
+
title: "Locate Blender Skill Asset", description: "Return the absolute path and size of an asset bundled with a listed Blender skill. The path is local to the MCP host.",
|
|
90
|
+
inputSchema: z.object({ name: z.string(), path: z.string() }).strict(),
|
|
65
91
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
66
|
-
}, async ({ name }) =>
|
|
92
|
+
}, async ({ name, path }) => {
|
|
93
|
+
try {
|
|
94
|
+
const asset = await getSkillAsset(name, path);
|
|
95
|
+
return { content: [{ type: "text", text: JSON.stringify(asset) }], structuredContent: asset };
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
return errorResult(error);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
67
101
|
return server;
|
|
68
102
|
}
|
package/dist/skills.d.ts
CHANGED
|
@@ -1,2 +1,15 @@
|
|
|
1
|
-
export declare
|
|
2
|
-
|
|
1
|
+
export declare function listSkills(): Promise<{
|
|
2
|
+
skills: {
|
|
3
|
+
name: string;
|
|
4
|
+
description: string;
|
|
5
|
+
references: string[];
|
|
6
|
+
assets: string[];
|
|
7
|
+
}[];
|
|
8
|
+
}>;
|
|
9
|
+
export declare function getSkill(name: string, document?: string): Promise<string>;
|
|
10
|
+
export declare function getSkillAsset(name: string, path: string): Promise<{
|
|
11
|
+
name: string;
|
|
12
|
+
path: string;
|
|
13
|
+
absolute_path: string;
|
|
14
|
+
bytes: number;
|
|
15
|
+
}>;
|
package/dist/skills.js
CHANGED
|
@@ -1,6 +1,90 @@
|
|
|
1
|
-
import { readFile } from "node:fs/promises";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
import { readFile, readdir, realpath, stat } from "node:fs/promises";
|
|
2
|
+
import { isAbsolute, relative, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
const root = fileURLToPath(new URL("../skills/", import.meta.url));
|
|
5
|
+
const legacyReferences = new Set(["modeling", "materials", "lighting-camera", "animation"]);
|
|
6
|
+
const skillName = /^blender-[a-z0-9-]+$/;
|
|
7
|
+
const documentPath = /^references\/[a-zA-Z0-9/_-]+\.md$/;
|
|
8
|
+
const assetPath = /^assets\/[a-zA-Z0-9][a-zA-Z0-9/._-]*$/;
|
|
9
|
+
function validParts(path) {
|
|
10
|
+
return !path.split("/").some(part => part === "" || part === "." || part === "..");
|
|
11
|
+
}
|
|
12
|
+
async function bundledFile(name, path) {
|
|
13
|
+
const corpus = await realpath(root);
|
|
14
|
+
const base = await realpath(resolve(root, name));
|
|
15
|
+
const skillLocal = relative(corpus, base);
|
|
16
|
+
if (skillLocal === ".." || skillLocal.startsWith("../") || skillLocal.startsWith("..\\") || isAbsolute(skillLocal)) {
|
|
17
|
+
throw new Error("Skill path escapes the bundled corpus");
|
|
18
|
+
}
|
|
19
|
+
const file = await realpath(resolve(base, path));
|
|
20
|
+
const local = relative(base, file);
|
|
21
|
+
if (local === ".." || local.startsWith("../") || local.startsWith("..\\") || isAbsolute(local)) {
|
|
22
|
+
throw new Error("Skill path escapes the bundled corpus");
|
|
23
|
+
}
|
|
24
|
+
if (!(await stat(file)).isFile())
|
|
25
|
+
throw new Error("Skill path is not a file");
|
|
26
|
+
return file;
|
|
27
|
+
}
|
|
28
|
+
async function files(name, folder) {
|
|
29
|
+
try {
|
|
30
|
+
const candidates = (await readdir(resolve(root, name, folder), { recursive: true }))
|
|
31
|
+
.map(path => `${folder}/${path}`)
|
|
32
|
+
.filter(path => validParts(path) && (folder === "assets" ? assetPath.test(path) : documentPath.test(path)))
|
|
33
|
+
.sort();
|
|
34
|
+
const checked = await Promise.all(candidates.map(async (path) => {
|
|
35
|
+
if (!(await stat(resolve(root, name, path))).isFile())
|
|
36
|
+
return null;
|
|
37
|
+
await bundledFile(name, path);
|
|
38
|
+
return path;
|
|
39
|
+
}));
|
|
40
|
+
return checked.filter((path) => path !== null);
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
if (error.code === "ENOENT")
|
|
44
|
+
return [];
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
async function requireSkill(name) {
|
|
49
|
+
if (!skillName.test(name))
|
|
50
|
+
throw new RangeError(`Unknown Blender skill '${name}'`);
|
|
51
|
+
try {
|
|
52
|
+
await bundledFile(name, "SKILL.md");
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
throw new RangeError(`Unknown Blender skill '${name}'. Call bl_list_skills for available names.`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
export async function listSkills() {
|
|
59
|
+
const entries = await readdir(root, { withFileTypes: true });
|
|
60
|
+
const names = entries.filter(entry => entry.isDirectory() && skillName.test(entry.name)).map(entry => entry.name).sort();
|
|
61
|
+
return { skills: await Promise.all(names.map(async (name) => {
|
|
62
|
+
const entry = await readFile(await bundledFile(name, "SKILL.md"), "utf8");
|
|
63
|
+
return {
|
|
64
|
+
name,
|
|
65
|
+
description: /^description:\s*(.+)$/m.exec(entry)?.[1] ?? "",
|
|
66
|
+
references: await files(name, "references"),
|
|
67
|
+
assets: await files(name, "assets"),
|
|
68
|
+
};
|
|
69
|
+
})) };
|
|
70
|
+
}
|
|
71
|
+
export async function getSkill(name, document = "SKILL.md") {
|
|
72
|
+
if (legacyReferences.has(name)) {
|
|
73
|
+
if (document !== "SKILL.md")
|
|
74
|
+
throw new RangeError("Legacy topic aliases do not have references");
|
|
75
|
+
return readFile(await bundledFile("blender-scene", `references/${name}.md`), "utf8");
|
|
76
|
+
}
|
|
77
|
+
await requireSkill(name);
|
|
78
|
+
if (document !== "SKILL.md" && (!validParts(document) || !documentPath.test(document) || !(await files(name, "references")).includes(document))) {
|
|
79
|
+
throw new RangeError(`Unknown reference '${document}' for '${name}'`);
|
|
80
|
+
}
|
|
81
|
+
return readFile(await bundledFile(name, document), "utf8");
|
|
82
|
+
}
|
|
83
|
+
export async function getSkillAsset(name, path) {
|
|
84
|
+
await requireSkill(name);
|
|
85
|
+
if (!validParts(path) || !assetPath.test(path) || !(await files(name, "assets")).includes(path)) {
|
|
86
|
+
throw new RangeError(`Unknown asset '${path}' for '${name}'`);
|
|
87
|
+
}
|
|
88
|
+
const absolute_path = await bundledFile(name, path);
|
|
89
|
+
return { name, path, absolute_path, bytes: (await stat(absolute_path)).size };
|
|
6
90
|
}
|
package/dist/tools.js
CHANGED
|
@@ -189,7 +189,7 @@ result = _info`),
|
|
|
189
189
|
];
|
|
190
190
|
BL_TOOLS.push(scriptTool("bl_health", "Inspect Blender", "Start or inspect this MCP session’s background Blender. Returns its version, PID, active file and scene; does not inspect an open desktop window.", {}, `import os
|
|
191
191
|
result = {"version": bpy.app.version_string, "pid": os.getpid(), "background": bpy.app.background,
|
|
192
|
-
"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.", {
|
|
192
|
+
"file": bpy.data.filepath, "dirty": _mcp_session["dirty"] or 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.", {
|
|
193
193
|
path: z.string().min(1), overwrite: z.boolean().default(false),
|
|
194
194
|
}, `import os
|
|
195
195
|
_path = _args["path"]
|
|
@@ -197,16 +197,20 @@ if not os.path.isabs(_path) or not _path.lower().endswith(".blend"):
|
|
|
197
197
|
raise ValueError("Provide an absolute .blend path")
|
|
198
198
|
if os.path.exists(_path) and not _args["overwrite"]:
|
|
199
199
|
raise ValueError("File exists; use a new path or explicitly set overwrite=true")
|
|
200
|
-
bpy.ops.wm.save_as_mainfile(filepath=_path, check_existing=False)
|
|
200
|
+
if "FINISHED" not in bpy.ops.wm.save_as_mainfile(filepath=_path, check_existing=False):
|
|
201
|
+
raise RuntimeError("Project save did not finish")
|
|
202
|
+
_mcp_session["dirty"] = False
|
|
201
203
|
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.", {
|
|
202
204
|
path: z.string().min(1), discard_unsaved: z.boolean().default(false),
|
|
203
205
|
}, `import os
|
|
204
206
|
_path = _args["path"]
|
|
205
207
|
if not os.path.isabs(_path) or not os.path.isfile(_path) or not _path.lower().endswith(".blend"):
|
|
206
208
|
raise ValueError("Provide an existing absolute .blend path")
|
|
207
|
-
if bpy.data.is_dirty and not _args["discard_unsaved"]:
|
|
209
|
+
if (_mcp_session["dirty"] or bpy.data.is_dirty) and not _args["discard_unsaved"]:
|
|
208
210
|
raise ValueError("The current project has unsaved changes; save first or explicitly set discard_unsaved=true")
|
|
209
|
-
bpy.ops.wm.open_mainfile(filepath=_path, use_scripts=False)
|
|
211
|
+
if "FINISHED" not in bpy.ops.wm.open_mainfile(filepath=_path, use_scripts=False):
|
|
212
|
+
raise RuntimeError("Project open did not finish")
|
|
213
|
+
_mcp_session["dirty"] = False
|
|
210
214
|
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.", {
|
|
211
215
|
name: z.string().min(1), property: z.enum(["location", "rotation_euler", "scale"]), frame: z.number().int(),
|
|
212
216
|
}, `_obj = bpy.data.objects.get(_args["name"])
|
package/dist/transport.d.ts
CHANGED
|
@@ -25,6 +25,6 @@ export declare class BlenderTransport {
|
|
|
25
25
|
private start;
|
|
26
26
|
private launch;
|
|
27
27
|
status(jobId: string): Promise<JobResult>;
|
|
28
|
-
execute(code: string, timeoutSeconds?: number): Promise<JobResult>;
|
|
28
|
+
execute(code: string, timeoutSeconds?: number, mutates?: boolean): Promise<JobResult>;
|
|
29
29
|
close(): Promise<void>;
|
|
30
30
|
}
|
package/dist/transport.js
CHANGED
|
@@ -152,7 +152,7 @@ export class BlenderTransport {
|
|
|
152
152
|
throw new Error("Unknown job_id for this MCP session; history is limited to 128 jobs and disappears on reconnect.");
|
|
153
153
|
return job;
|
|
154
154
|
}
|
|
155
|
-
async execute(code, timeoutSeconds = 120) {
|
|
155
|
+
async execute(code, timeoutSeconds = 120, mutates = true) {
|
|
156
156
|
if (!code || code.length > 1_000_000)
|
|
157
157
|
throw new Error("Python source must contain 1–1000000 characters.");
|
|
158
158
|
await this.start();
|
|
@@ -166,7 +166,7 @@ export class BlenderTransport {
|
|
|
166
166
|
return new Promise((resolve, reject) => {
|
|
167
167
|
const timer = setTimeout(() => reject(new Error(`Blender is still running; job_id=${id}. Use bl_job_status on this MCP session before retrying. No automatic retry or cancellation was sent.`)), timeoutSeconds * 1000);
|
|
168
168
|
this.active = { id, finish: result => { clearTimeout(timer); resolve(result); } };
|
|
169
|
-
this.child.stdin.write(JSON.stringify({ job_id: id, code }) + "\n", error => { if (error)
|
|
169
|
+
this.child.stdin.write(JSON.stringify({ job_id: id, code, mutates }) + "\n", error => { if (error)
|
|
170
170
|
this.fail(error); });
|
|
171
171
|
});
|
|
172
172
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fnf-blender-mcp",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.2.2",
|
|
4
|
+
"description": "Local MCP server for Blender with a background process and offline creative skills.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"engines": {
|
|
@@ -15,7 +15,6 @@
|
|
|
15
15
|
"dist",
|
|
16
16
|
"skills",
|
|
17
17
|
"README.md",
|
|
18
|
-
"RELEASING.md",
|
|
19
18
|
"UPSTREAM.md",
|
|
20
19
|
"LICENSE",
|
|
21
20
|
"scripts/*.py"
|
|
@@ -42,20 +41,11 @@
|
|
|
42
41
|
"access": "public",
|
|
43
42
|
"registry": "https://registry.npmjs.org/"
|
|
44
43
|
},
|
|
45
|
-
"homepage": "https://www.npmjs.com/package/fnf-blender-mcp",
|
|
46
44
|
"keywords": [
|
|
47
45
|
"blender",
|
|
48
46
|
"mcp",
|
|
49
47
|
"higgsfield",
|
|
50
48
|
"3d",
|
|
51
49
|
"bpy"
|
|
52
|
-
]
|
|
53
|
-
"repository": {
|
|
54
|
-
"type": "git",
|
|
55
|
-
"url": "git+https://github.com/higgsfield-ai/fnf-local-pluging-bridge-mcp.git",
|
|
56
|
-
"directory": "blender"
|
|
57
|
-
},
|
|
58
|
-
"bugs": {
|
|
59
|
-
"url": "https://github.com/higgsfield-ai/fnf-local-pluging-bridge-mcp/issues"
|
|
60
|
-
}
|
|
50
|
+
]
|
|
61
51
|
}
|
|
@@ -9,6 +9,7 @@ import traceback
|
|
|
9
9
|
PREFIX = "HF_BLENDER_JSON:"
|
|
10
10
|
MAX_RESULT = 4 * 1024 * 1024
|
|
11
11
|
MAX_REQUEST = 8 * 1024 * 1024
|
|
12
|
+
SESSION = {"dirty": False}
|
|
12
13
|
|
|
13
14
|
|
|
14
15
|
class BoundedText(io.TextIOBase):
|
|
@@ -30,10 +31,16 @@ def execute(request):
|
|
|
30
31
|
code = request["code"]
|
|
31
32
|
if not isinstance(code, str) or not 0 < len(code) <= 1_000_000:
|
|
32
33
|
raise ValueError("Invalid Python source length")
|
|
34
|
+
mutates = request.get("mutates", True)
|
|
35
|
+
if not isinstance(mutates, bool):
|
|
36
|
+
raise ValueError("Invalid mutation flag")
|
|
33
37
|
stdout, stderr = BoundedText(), BoundedText()
|
|
34
38
|
response = {"job_id": job_id, "state": "completed"}
|
|
35
39
|
try:
|
|
36
|
-
namespace = {"__name__": "__blender_mcp__"}
|
|
40
|
+
namespace = {"__name__": "__blender_mcp__", "_mcp_session": SESSION}
|
|
41
|
+
# Background RNA writes may leave bpy.data.is_dirty false, even after partial failure.
|
|
42
|
+
if mutates:
|
|
43
|
+
SESSION["dirty"] = True
|
|
37
44
|
with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
|
|
38
45
|
exec(compile(code, "<blender-mcp>", "exec"), namespace)
|
|
39
46
|
result = namespace.get("result")
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: blender-animation
|
|
3
|
+
description: Dynamic-by-default motion — the motion plan, semantic rigs, deliberate interpolation, constraints and drivers, surface contact, rotational aliasing, visibility keying, dynamic defaults per subject type, and the motion audit. For motion produced by a rigid-body solver rather than keyframes, use blender-destruction.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Animation
|
|
7
|
+
|
|
8
|
+
Read [blender-scene](../blender-scene/SKILL.md) for the local session contract.
|
|
9
|
+
Tool names below use its capability mapping when the named interface is absent;
|
|
10
|
+
read only the relevant rows in [blender-volatile](../blender-volatile/SKILL.md).
|
|
11
|
+
|
|
12
|
+
Motion is part of the default deliverable: unless the user explicitly asked
|
|
13
|
+
for a static scene, the Scene Passport defines at least one purposeful beat.
|
|
14
|
+
"Dynamic" means the viewer can watch something change and read it — not that
|
|
15
|
+
everything moves at once.
|
|
16
|
+
|
|
17
|
+
## Motion plan
|
|
18
|
+
|
|
19
|
+
Pin down before keying anything:
|
|
20
|
+
|
|
21
|
+
- fps, frame range, playback duration;
|
|
22
|
+
- rest pose and beat structure: anticipation → action → settle;
|
|
23
|
+
- the carrier of each beat — object, camera, light, material, or environment;
|
|
24
|
+
- the loop boundary, if the motion loops;
|
|
25
|
+
- constraints, parenting, and control objects involved.
|
|
26
|
+
|
|
27
|
+
One clear primary action, restrained secondaries, and at least one stable
|
|
28
|
+
frame suitable for inspection and thumbnails.
|
|
29
|
+
|
|
30
|
+
## Build animation
|
|
31
|
+
|
|
32
|
+
- Bulk transform/lens keys → `bl_keyframe_properties`; turntable, camera
|
|
33
|
+
orbit/dolly, idle → `bl_create_motion_preset`; frame inspection →
|
|
34
|
+
`bl_set_frame`.
|
|
35
|
+
- Drop to `bl_execute` only for what typed tools cannot express: F-curve
|
|
36
|
+
edits, constraints, drivers, armatures, shape keys, simulations, NLA.
|
|
37
|
+
- Key rigs and semantic controls, not a pile of unrelated child transforms.
|
|
38
|
+
Key only properties that need to change.
|
|
39
|
+
- Choose interpolation on purpose: Bezier for organic motion, linear for
|
|
40
|
+
constant rates, constant for stepped switches.
|
|
41
|
+
- Pick quaternion or Euler deliberately so rotations do not flip mid-move;
|
|
42
|
+
connector boundaries speak radians.
|
|
43
|
+
- Object animation and data animation are separate sets. Lens, `clip_start`,
|
|
44
|
+
and `dof.focus_distance` keys belong to `camera.data`. Follow Path
|
|
45
|
+
`offset_factor` belongs to the constraint and is animated on its owning
|
|
46
|
+
object, through `constraints[...].offset_factor`. Retime both object and
|
|
47
|
+
data actions, filtering their correct action slots (`blender-volatile`),
|
|
48
|
+
then check each shot's transform, path, lens, and focus key ranges.
|
|
49
|
+
- Simulations must be deterministic enough that the audited frames reproduce.
|
|
50
|
+
|
|
51
|
+
## Constraints and drivers
|
|
52
|
+
|
|
53
|
+
- **`Copy Location` on a spinning child is forbidden.** Any residual offset
|
|
54
|
+
between target and owner orbits with the spin, and the part reads as about
|
|
55
|
+
to detach. Only rotation can be compensated.
|
|
56
|
+
- Copy the steering component alone, in the control object's space
|
|
57
|
+
(CUSTOM to CUSTOM, single axis, mix AFTER). Copying full world rotation
|
|
58
|
+
drags support noise into the tilt.
|
|
59
|
+
- The local space of a bone-parented child is useless: its own rotation is
|
|
60
|
+
static and the motion lives in the bone. Name the control object explicitly.
|
|
61
|
+
- **Rolling contact is verified arithmetically.** Rotation per frame equals
|
|
62
|
+
distance per frame divided by radius, and the residual stays sub-millimetre.
|
|
63
|
+
The driver's sign follows the direction of the part's local axis; the
|
|
64
|
+
contact point holding still is the proof.
|
|
65
|
+
- Ray-based ground tracking over a faceted support mesh returns noise, not
|
|
66
|
+
travel. Disable it and solve the surface response deliberately.
|
|
67
|
+
|
|
68
|
+
## Surface contact solve
|
|
69
|
+
|
|
70
|
+
For anything that must stay planted on uneven ground while it moves:
|
|
71
|
+
|
|
72
|
+
1. One ray per support point gives the gaps.
|
|
73
|
+
2. The mean gap is the height correction.
|
|
74
|
+
3. Compute the slope angle with `atan2(height_difference, support_distance)`.
|
|
75
|
+
Use the full horizontal distance between the two compared support points,
|
|
76
|
+
not the half-span. For points at `-a` and `+a`, the denominator is `2*a`.
|
|
77
|
+
Resolve the sign and roll/pitch axis in the rig's local frame and check
|
|
78
|
+
the resulting contact gaps; banked or uneven supports may need a fitted
|
|
79
|
+
support plane rather than two independent slopes.
|
|
80
|
+
4. Two passes per frame for convergence.
|
|
81
|
+
5. Ease in and out over roughly 20 frames with a quintic so no correction
|
|
82
|
+
pops on entry.
|
|
83
|
+
|
|
84
|
+
A baked contact holds height but does not pick up lateral slope: it passes on
|
|
85
|
+
straights and drifts on arcs. Level ground under a tilted body means the body
|
|
86
|
+
is wrong, not the ground.
|
|
87
|
+
|
|
88
|
+
## Rotational aliasing
|
|
89
|
+
|
|
90
|
+
An N-fold symmetric spinner strobes when its step per frame lands near a
|
|
91
|
+
multiple of its symmetry sector — the blades nearly fill the previous
|
|
92
|
+
positions and the eye reads a jerk or a reversal. Hold a constant step half a
|
|
93
|
+
sector away from any multiple of the sector, then re-phase after the shot so
|
|
94
|
+
the rotation stays continuous.
|
|
95
|
+
|
|
96
|
+
Blur decides that read more than the keys do. A shutter near 0.2 at one step
|
|
97
|
+
is no smear at all; 0.75 over 12 steps is the working baseline, raised toward
|
|
98
|
+
0.95 by key for the fastest passage. The viewport never renders blur — spin
|
|
99
|
+
and smear are judged from a render only.
|
|
100
|
+
|
|
101
|
+
## Visibility keying
|
|
102
|
+
|
|
103
|
+
- Visibility switches are keyed with constant interpolation.
|
|
104
|
+
- **Constraint targets hide in render only.** `hide_viewport` drops an object
|
|
105
|
+
from the depsgraph, and every constraint reading it gets a stale matrix.
|
|
106
|
+
- Everything else hides in render and viewport together. A render-only hide
|
|
107
|
+
leaves the viewport lying about framing: geometry sitting millimetres in
|
|
108
|
+
front of a lens still blocks the preview.
|
|
109
|
+
- Props belong to the shots that need them, not to every range sharing a
|
|
110
|
+
state.
|
|
111
|
+
- `animation_data_clear()` removes visibility keys along with everything else.
|
|
112
|
+
|
|
113
|
+
## Dynamic defaults
|
|
114
|
+
|
|
115
|
+
When the brief names no motion:
|
|
116
|
+
|
|
117
|
+
- product/prop — slow turntable or camera orbit, settling on the hero angle;
|
|
118
|
+
- environment — subtle camera drift plus a restrained practical-light change;
|
|
119
|
+
- character/creature — idle breathing or weight shift, only if the rig
|
|
120
|
+
supports it;
|
|
121
|
+
- abstract — one focal transformation with a small secondary response.
|
|
122
|
+
|
|
123
|
+
Never invent character acting, destructive simulation, or a camera that never
|
|
124
|
+
stops when subtle motion already satisfies the dynamic requirement.
|
|
125
|
+
|
|
126
|
+
## Motion audit
|
|
127
|
+
|
|
128
|
+
Sample at minimum: first/rest frame, departure, peak of the fastest change,
|
|
129
|
+
settle, final frame plus the loop seam where one exists. Inspect animated
|
|
130
|
+
properties through `bl_get_object` and `bl_execute`, and view camera renders
|
|
131
|
+
at selected frames using `bl_set_frame` and `bl_render`. This replaces the
|
|
132
|
+
unavailable `bl_audit_motion` and `bl_render_contact_sheet` tools. At each
|
|
133
|
+
sample check framing, collisions
|
|
134
|
+
and intersections, visibility, constraint behavior, shading continuity, and
|
|
135
|
+
motion direction. Open F-curves only when the typed audit cannot explain a
|
|
136
|
+
failure — a still render proves nothing about animation.
|
|
137
|
+
|
|
138
|
+
The pass fails if expected properties carry no keys, motion is imperceptible,
|
|
139
|
+
objects teleport, rotations flip, the camera loses the subject, or the loop
|
|
140
|
+
jumps. Fix and sample again.
|