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.
Files changed (62) hide show
  1. package/README.md +18 -27
  2. package/dist/server.js +40 -6
  3. package/dist/skills.d.ts +15 -2
  4. package/dist/skills.js +89 -5
  5. package/dist/tools.js +8 -4
  6. package/dist/transport.d.ts +1 -1
  7. package/dist/transport.js +2 -2
  8. package/package.json +3 -13
  9. package/scripts/background_runner.py +8 -1
  10. package/skills/blender-animation/SKILL.md +140 -0
  11. package/skills/blender-audit-finalize/SKILL.md +139 -0
  12. package/skills/blender-camera-blocking/SKILL.md +32 -0
  13. package/skills/blender-camera-blocking/assets/Blockstage-0.3.0.zip +0 -0
  14. package/skills/blender-camera-blocking/references/addon-installation.md +17 -0
  15. package/skills/blender-camera-blocking/references/character-staging.md +19 -0
  16. package/skills/blender-camera-blocking/references/layout-and-assets.md +19 -0
  17. package/skills/blender-camera-blocking/references/live-construction.md +7 -0
  18. package/skills/blender-camera-led-assembly/SKILL.md +48 -0
  19. package/skills/blender-camera-led-assembly/references/blender-mechanics.md +45 -0
  20. package/skills/blender-camera-led-assembly/references/camera-choreography.md +25 -0
  21. package/skills/blender-camera-led-assembly/references/case-template.md +23 -0
  22. package/skills/blender-camera-led-assembly/references/environment-assembly.md +27 -0
  23. package/skills/blender-camera-led-assembly/references/product-assembly.md +25 -0
  24. package/skills/blender-camera-led-assembly/references/validation-delivery.md +33 -0
  25. package/skills/blender-destruction/SKILL.md +24 -0
  26. package/skills/blender-destruction/references/assembly.md +23 -0
  27. package/skills/blender-destruction/references/delivery.md +29 -0
  28. package/skills/blender-destruction/references/simulation.md +25 -0
  29. package/skills/blender-generation/SKILL.md +139 -0
  30. package/skills/blender-greybox/SKILL.md +103 -0
  31. package/skills/blender-hdri/SKILL.md +133 -0
  32. package/skills/blender-lighting-camera/SKILL.md +217 -0
  33. package/skills/blender-lookdev/SKILL.md +99 -0
  34. package/skills/blender-modeling/SKILL.md +181 -0
  35. package/skills/blender-pbr/SKILL.md +155 -0
  36. package/skills/blender-scene/SKILL.md +66 -1
  37. package/skills/blender-scene-spec/SKILL.md +177 -0
  38. package/skills/blender-stylized-materials/SKILL.md +34 -0
  39. package/skills/blender-stylized-materials/assets/BUNDLE.md +19 -0
  40. package/skills/blender-stylized-materials/assets/cube-demo.blend +0 -0
  41. package/skills/blender-stylized-materials/assets/cube-preview.png +0 -0
  42. package/skills/blender-stylized-materials/assets/materials.blend +0 -0
  43. package/skills/blender-stylized-materials/assets/previews/BACKGROUND.png +0 -0
  44. package/skills/blender-stylized-materials/assets/previews/CANVAS.png +0 -0
  45. package/skills/blender-stylized-materials/assets/previews/CEL.png +0 -0
  46. package/skills/blender-stylized-materials/assets/previews/COMIC_PULSE.png +0 -0
  47. package/skills/blender-stylized-materials/assets/previews/DRYBRUSH.png +0 -0
  48. package/skills/blender-stylized-materials/assets/previews/HALFTONE.png +0 -0
  49. package/skills/blender-stylized-materials/assets/previews/MANGA.png +0 -0
  50. package/skills/blender-stylized-materials/assets/previews/METAL.png +0 -0
  51. package/skills/blender-stylized-materials/assets/previews/NEON.png +0 -0
  52. package/skills/blender-stylized-materials/assets/previews/PAINT.png +0 -0
  53. package/skills/blender-stylized-materials/assets/previews/WATERCOLOR.png +0 -0
  54. package/skills/blender-stylized-materials/assets/stylized-materials-addon.zip +0 -0
  55. package/skills/blender-stylized-materials/references/material-engineering.md +31 -0
  56. package/skills/blender-stylized-materials/references/ready-to-use-bundle.md +23 -0
  57. package/skills/blender-stylized-materials/references/scene-delivery.md +27 -0
  58. package/skills/blender-stylized-materials/references/visual-library.md +21 -0
  59. package/skills/blender-volatile/SKILL.md +150 -0
  60. package/skills/use-blender/SKILL.md +2 -1
  61. package/skills/use-blender/references/installation.md +5 -5
  62. package/RELEASING.md +0 -11
package/README.md CHANGED
@@ -1,27 +1,29 @@
1
- # Higgsfield use Blender
1
+ # Higgsfield Blender MCP
2
2
 
3
- The independent `fnf-blender-mcp` package in `fnf-local-pluging-bridge-mcp/blender` controls a dedicated background Blender process. No add-on, HTTP listener, WebSocket, cloud account or open Blender window is required.
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. The After Effects package at the repository root is independent.
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
- ## Setup
11
+ ## Requirements
12
12
 
13
- Requires Node.js 24+ with npm and Blender 4.2+. Version 0.2.0 introduces the background runtime; version 0.1.0 uses the previous add-on. Verify that 0.2.0 is published before installing it; this source change alone does not publish a release.
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" --registry=https://registry.npmjs.org/ fnf-blender-mcp@0.2.0
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. See the [installation skill](skills/use-blender/references/installation.md) for PowerShell and migration steps.
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
- ## Session lifecycle
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
- In 0.2.0, `launch`, `install-addon`, `bl_screenshot`, bridge discovery, PID selection and runtime-directory overrides are removed. 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.
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
- See [RELEASING.md](RELEASING.md) for release steps. The setup skill is mirrored in fnf-mcp-server's `/use-blender` command; keep its body and references synchronized. See [UPSTREAM.md](UPSTREAM.md) for provenance and [LICENSE](LICENSE) for the MIT license.
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, SKILL_NAMES } from "./skills.js";
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
- return await formatResult(await transport.execute(tool.code(args), tool.name === "bl_render" ? 300 : 120));
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 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(),
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 }) => ({ content: [{ type: "text", text: await getSkill(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 const SKILL_NAMES: readonly ["blender-scene", "modeling", "materials", "lighting-camera", "animation"];
2
- export declare function getSkill(name: typeof SKILL_NAMES[number]): Promise<string>;
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
- 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");
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"])
@@ -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.0",
4
- "description": "Higgsfield use Blender: local stdio MCP controlling a background Blender process without add-ons.",
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.