fnf-blender-mcp 0.2.0 → 0.2.1

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 +9 -5
  2. package/RELEASING.md +3 -3
  3. package/dist/server.js +40 -6
  4. package/dist/skills.d.ts +15 -2
  5. package/dist/skills.js +89 -5
  6. package/dist/tools.js +8 -4
  7. package/dist/transport.d.ts +1 -1
  8. package/dist/transport.js +2 -2
  9. package/package.json +1 -1
  10. package/scripts/background_runner.py +8 -1
  11. package/skills/blender-animation/SKILL.md +140 -0
  12. package/skills/blender-audit-finalize/SKILL.md +139 -0
  13. package/skills/blender-camera-blocking/SKILL.md +32 -0
  14. package/skills/blender-camera-blocking/assets/Blockstage-0.3.0.zip +0 -0
  15. package/skills/blender-camera-blocking/references/addon-installation.md +17 -0
  16. package/skills/blender-camera-blocking/references/character-staging.md +19 -0
  17. package/skills/blender-camera-blocking/references/layout-and-assets.md +19 -0
  18. package/skills/blender-camera-blocking/references/live-construction.md +7 -0
  19. package/skills/blender-camera-led-assembly/SKILL.md +48 -0
  20. package/skills/blender-camera-led-assembly/references/blender-mechanics.md +45 -0
  21. package/skills/blender-camera-led-assembly/references/camera-choreography.md +25 -0
  22. package/skills/blender-camera-led-assembly/references/case-template.md +23 -0
  23. package/skills/blender-camera-led-assembly/references/environment-assembly.md +27 -0
  24. package/skills/blender-camera-led-assembly/references/product-assembly.md +25 -0
  25. package/skills/blender-camera-led-assembly/references/validation-delivery.md +33 -0
  26. package/skills/blender-destruction/SKILL.md +24 -0
  27. package/skills/blender-destruction/references/assembly.md +23 -0
  28. package/skills/blender-destruction/references/delivery.md +29 -0
  29. package/skills/blender-destruction/references/simulation.md +25 -0
  30. package/skills/blender-generation/SKILL.md +139 -0
  31. package/skills/blender-greybox/SKILL.md +103 -0
  32. package/skills/blender-hdri/SKILL.md +133 -0
  33. package/skills/blender-lighting-camera/SKILL.md +217 -0
  34. package/skills/blender-lookdev/SKILL.md +99 -0
  35. package/skills/blender-modeling/SKILL.md +181 -0
  36. package/skills/blender-pbr/SKILL.md +155 -0
  37. package/skills/blender-scene/SKILL.md +66 -1
  38. package/skills/blender-scene-spec/SKILL.md +177 -0
  39. package/skills/blender-stylized-materials/SKILL.md +34 -0
  40. package/skills/blender-stylized-materials/assets/BUNDLE.md +19 -0
  41. package/skills/blender-stylized-materials/assets/cube-demo.blend +0 -0
  42. package/skills/blender-stylized-materials/assets/cube-preview.png +0 -0
  43. package/skills/blender-stylized-materials/assets/materials.blend +0 -0
  44. package/skills/blender-stylized-materials/assets/previews/BACKGROUND.png +0 -0
  45. package/skills/blender-stylized-materials/assets/previews/CANVAS.png +0 -0
  46. package/skills/blender-stylized-materials/assets/previews/CEL.png +0 -0
  47. package/skills/blender-stylized-materials/assets/previews/COMIC_PULSE.png +0 -0
  48. package/skills/blender-stylized-materials/assets/previews/DRYBRUSH.png +0 -0
  49. package/skills/blender-stylized-materials/assets/previews/HALFTONE.png +0 -0
  50. package/skills/blender-stylized-materials/assets/previews/MANGA.png +0 -0
  51. package/skills/blender-stylized-materials/assets/previews/METAL.png +0 -0
  52. package/skills/blender-stylized-materials/assets/previews/NEON.png +0 -0
  53. package/skills/blender-stylized-materials/assets/previews/PAINT.png +0 -0
  54. package/skills/blender-stylized-materials/assets/previews/WATERCOLOR.png +0 -0
  55. package/skills/blender-stylized-materials/assets/stylized-materials-addon.zip +0 -0
  56. package/skills/blender-stylized-materials/references/material-engineering.md +31 -0
  57. package/skills/blender-stylized-materials/references/ready-to-use-bundle.md +23 -0
  58. package/skills/blender-stylized-materials/references/scene-delivery.md +27 -0
  59. package/skills/blender-stylized-materials/references/visual-library.md +21 -0
  60. package/skills/blender-volatile/SKILL.md +150 -0
  61. package/skills/use-blender/SKILL.md +2 -1
  62. package/skills/use-blender/references/installation.md +5 -5
package/README.md CHANGED
@@ -10,12 +10,12 @@ Each MCP connection owns one process. Its scene persists between commands; it ca
10
10
 
11
11
  ## Setup
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
+ 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. Install the latest published version (0.2.0 or newer); rerun the install command to update an existing installation.
14
14
 
15
15
  ```sh
16
- npm view fnf-blender-mcp@0.2.0 version --registry=https://registry.npmjs.org/
16
+ npm view fnf-blender-mcp@latest version --registry=https://registry.npmjs.org/
17
17
  blenderDir="$HOME/.higgsfield/blender-mcp"
18
- npm install --prefix "$blenderDir" --registry=https://registry.npmjs.org/ fnf-blender-mcp@0.2.0
18
+ npm install --prefix "$blenderDir" --registry=https://registry.npmjs.org/ fnf-blender-mcp@latest
19
19
  blenderCli="$blenderDir/node_modules/fnf-blender-mcp/dist/cli.js"
20
20
  node "$blenderCli" doctor --blender "/absolute/path/to/blender"
21
21
  node "$blenderCli" config --blender "/absolute/path/to/blender" --format json
@@ -36,10 +36,12 @@ The server starts Blender on its first execution call. `doctor` checks a separat
36
36
  | Files | `bl_save_project`, `bl_open_project` |
37
37
  | Evidence | `bl_render` |
38
38
  | Python/status | `bl_execute`, `bl_job_status` |
39
- | Offline guidance | `bl_get_skill` |
39
+ | Offline guidance | `bl_list_skills`, `bl_get_skill`, `bl_get_skill_asset` |
40
40
 
41
41
  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
42
 
43
+ 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.
44
+
43
45
  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
46
 
45
47
  ## Session lifecycle
@@ -61,8 +63,10 @@ npm run test:package
61
63
  BLENDER_EXECUTABLE=/absolute/path/to/blender npm run test:live
62
64
  ```
63
65
 
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.
66
+ 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 21 tools, offline guidance, asset lookup and the absence of runtime add-on files. `test:live` uses real Blender for geometry, materials, keyframes, file guards and a small Cycles render in disposable files.
65
67
 
66
68
  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
69
 
68
70
  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.
71
+
72
+ 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/RELEASING.md CHANGED
@@ -3,9 +3,9 @@
3
3
  Run commands from this `blender/` directory. Its package and lockfile are independent of After Effects.
4
4
 
5
5
  1. Run `npm ci`, `npm test`, `npm run typecheck`, `npm run test:package`, and `test:live` with a real `BLENDER_EXECUTABLE` when available.
6
- 2. Version 0.2.0 is prepared for the transition to a background process. Verify that it is unpublished with `npm view fnf-blender-mcp versions --json`. Never overwrite an existing npm version; bump both manifests for subsequent releases.
7
- 3. Keep the version in README.md and skills/use-blender aligned with the fnf-mcp-server command bundle. Review migration instructions for removed commands and tools.
8
- 4. Run `npm pack --json`, review the contents, then publish the reviewed archive with `npm publish ./fnf-blender-mcp-0.2.0.tgz --access public --registry=https://registry.npmjs.org/` when publication is authorized.
6
+ 2. Version 0.2.1 bundles the Blender craft skills, including the additions from PR #25. Verify that it is unpublished with `npm view fnf-blender-mcp versions --json`. Never overwrite an existing npm version; bump both manifests for subsequent releases.
7
+ 3. Keep README.md, skills/use-blender and the fnf-mcp-server command bundle on `fnf-blender-mcp@latest`; preserve historical migration versions. Review migration instructions for removed commands and tools.
8
+ 4. Merge the source changes and version metadata into `main`, check out its exact commit in a clean worktree, rerun the checks, then run `npm pack --json`, review the contents, then publish the reviewed archive with `npm publish ./fnf-blender-mcp-0.2.1.tgz --access public --registry=https://registry.npmjs.org/` when publication is authorized.
9
9
  5. Verify the public version and checksum and test a fresh registry installation. Publish the package before deploying the fnf-mcp-server instructions that require it.
10
10
 
11
11
  Merging the source MR does not publish npm. Version 0.1.0 still requires the old add-on; it is not a fallback for 0.2.0 setup. New clients need a refreshed configuration containing BLENDER_EXECUTABLE. Existing desktop files and add-ons are not removed by this release.
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,6 +1,6 @@
1
1
  {
2
2
  "name": "fnf-blender-mcp",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Higgsfield use Blender: local stdio MCP controlling a background Blender process without add-ons.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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.
@@ -0,0 +1,139 @@
1
+ ---
2
+ name: blender-audit-finalize
3
+ description: Audit a local Blender scene against its specification, measure structural, motion and visual failures, repair them, and save requested deliverables under the local recovery policy.
4
+ ---
5
+
6
+ # Audit and finalize
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
+ The audit compares the scene to the Scene Passport — not to memory, and not to
13
+ the arguments of the calls that built it. A build passes only when structural,
14
+ motion, and visual evidence all agree.
15
+
16
+ ## Structural audit
17
+
18
+ Run `bl_validate_scene`, diff against the pre-build snapshot with
19
+ `bl_scene_diff`, and inspect the important objects with `bl_get_object`
20
+ (`bl_execute` only for facts the typed reads do not expose). Confirm:
21
+
22
+ - every expected object exists exactly once, under its semantic name;
23
+ - dimensions, transforms, origins, parents, collections, visibility match;
24
+ - active camera, frame range, fps, render engine, resolution match, and
25
+ `frame_end` reaches the last marker and the last key;
26
+ - material slots and texture links resolve;
27
+ - generated replacements sit inside their proxy footprints;
28
+ - no stray defaults, `.001` duplicates, orphan stand-ins, or hidden failures;
29
+ - related objects found under every prefix they use — one prefix search is
30
+ not a sweep;
31
+ - hero/export meshes have sane normals, manifold state, and density.
32
+
33
+ ## Motion audit
34
+
35
+ Mandatory for dynamic-default scenes; skipped only on an explicit static
36
+ request.
37
+
38
+ - Audit motion from fresh scene reads and `bl_execute`; when diagnosing a
39
+ failure, enumerate animated objects/properties, key times, and interpolation.
40
+ This package does not provide `bl_audit_motion`.
41
+ - Sample first/rest, departure, peak, settle, and final/loop frames.
42
+ - Check collisions, clipping, constraint jumps, rotation flips, camera
43
+ framing, visibility, loop continuity.
44
+ - The promised motion must be perceptible and purposeful.
45
+
46
+ A still image cannot pass this audit.
47
+
48
+ ## Visual audit
49
+
50
+ 1. Viewed camera render for composition; inspect datablocks, modifiers and
51
+ rigs for editability. A desktop viewport check is unavailable locally.
52
+ 2. Inspect active-camera material, light, shadow, transparency, and engine
53
+ settings through fresh reads, then view a `bl_render` output. The package
54
+ does not provide `bl_audit_render`.
55
+ 3. For dynamic scenes, call `bl_set_frame` and `bl_render` at representative
56
+ frames; view each result. There is no `bl_render_contact_sheet` tool.
57
+ 4. View the returned evidence — success JSON says nothing about appearance.
58
+ And view it at scale: image previews downsample, so few-pixel features
59
+ average away to nothing. Crop 1:1 and count bright pixels numerically
60
+ before concluding something did not render.
61
+ 5. **Unreachable evidence fallback** — when a render/screenshot URL cannot
62
+ be opened (network policy, expired link), report that retrieval failure
63
+ and obtain equivalent evidence at an accessible local path. Write camera
64
+ renders via `scene.render.filepath` +
65
+ `bpy.ops.render.render(write_still=True)`; obtain a local viewport capture
66
+ if the claim specifically concerns viewport state. Actually view the
67
+ replacement evidence. If it cannot be viewed either, mark the relevant
68
+ visual check `blocked`, give the user the available paths/URL, and never
69
+ substitute numeric proxies for appearance. Numeric proxies cover
70
+ structure and motion only.
71
+ One useful framing proxy:
72
+ `bpy_extras.object_utils.world_to_camera_view(scene, cam, world_co)`
73
+ returns NDC — x/y in `[0,1]` **with z > 0** means in-frame (z ≤ 0 is
74
+ behind the camera) — but NDC cannot tell a cylinder from a cactus.
75
+
76
+ Check silhouette hierarchy, scale cues, contact, depth separation, texture
77
+ scale, highlight/shadow detail, generated/local style coherence, and delivery
78
+ framing. One extra rule: **no asset may read as an unresolved primitive from
79
+ the active camera** unless its passport fidelity is `blockout`. A bare
80
+ cylinder standing in for a cactus, or a uniform sphere for a rock, fails this
81
+ check even when every structural metric passes.
82
+
83
+ ## Specialist audits
84
+
85
+ Owned by their modules, executed here at finalize time when the Scene
86
+ Passport declares the matching work:
87
+
88
+ - **Cinematic lighting** (cinematic, commercial, product, night, atmosphere,
89
+ reference-matched): the full "Lighting audit" in
90
+ `blender-lighting-camera`. If isolated or grayscale evidence cannot
91
+ be produced, the lighting audit is `blocked`, not `passed`.
92
+ - **Generated environment**: the "Audit gate" in `blender-hdri`. Record
93
+ the SDR limitation; a GPT Image PNG is never reported as true HDR/EXR.
94
+ - **Generated PBR material**: the "Audit gate" in `blender-pbr`.
95
+ Luminance-derived maps are inferred, not measured.
96
+
97
+ ## Measuring a complaint
98
+
99
+ - Measure the quantity that was complained about, in world space over time —
100
+ never the nearest available proxy. A perfect reading on the wrong quantity
101
+ closes the wrong case, and the complaint returns.
102
+ - A clean zero can be the failure. A component that never varies proves rigid
103
+ parenting as readily as correctness; pick the reference frame the physical
104
+ claim is about — for a wheel that is the ground, not the body.
105
+ - A hidden object returns a stale matrix. Measure on a frame where it is
106
+ visible.
107
+ - `scene.ray_cast` follows viewport visibility, not render visibility. When
108
+ the ray and the render disagree, the render is the evidence.
109
+
110
+ ## Repair loop
111
+
112
+ Fix the largest concrete failure first, then rerun the affected audit. No
113
+ polishing of secondary detail while scale, framing, contact, or motion is
114
+ wrong. A blocked check is reported as blocked — never dressed up as a pass.
115
+
116
+ ## Finalize
117
+
118
+ - Modifiers, rigs, materials, and generated assets stay editable unless the
119
+ deliverable explicitly demands destructive application.
120
+ - Proxies are removed only after their replacements pass; otherwise they are
121
+ parked predictably and reported.
122
+ - Restore a useful frame and the intended active camera.
123
+ - Record generated job ids, models, estimates, imported object names.
124
+ - Report created/modified/deleted objects with the audit evidence.
125
+
126
+ When `bl_finalize_build` is exposed, run it once after repairs and inspect
127
+ its documented result; a `valid` field must be true when that is the contract.
128
+ Otherwise use the core manual-finalize fallback and report each audit result
129
+ with its actual evidence. Do not invent a tool response or silently convert a
130
+ blocked generation, export, or visual check into a pass.
131
+
132
+ ## Save policy
133
+
134
+ Follow blender-scene's local save and recovery contract. Recovery copies
135
+ precede broad/destructive edits; save requested .blend outputs before
136
+ reconnecting or replacing the active project. Preserve useful partial work
137
+ even when an audit is blocked, clearly reporting its status. Confirm an
138
+ ambiguous overwrite target; do not overwrite or pack resources merely as a
139
+ side effect of audit/finalize. Report the verified saved path.