fnf-blender-mcp 0.1.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 (72) hide show
  1. package/README.md +31 -53
  2. package/RELEASING.md +11 -0
  3. package/UPSTREAM.md +1 -1
  4. package/dist/cli.js +34 -46
  5. package/dist/index.js +8 -1
  6. package/dist/schemas.d.ts +1 -9
  7. package/dist/schemas.js +3 -14
  8. package/dist/server.js +41 -7
  9. package/dist/skills.d.ts +15 -2
  10. package/dist/skills.js +89 -5
  11. package/dist/tools.js +10 -59
  12. package/dist/transport.d.ts +28 -29
  13. package/dist/transport.js +169 -66
  14. package/package.json +14 -5
  15. package/scripts/background_runner.py +76 -0
  16. package/skills/blender-animation/SKILL.md +140 -0
  17. package/skills/blender-audit-finalize/SKILL.md +139 -0
  18. package/skills/blender-camera-blocking/SKILL.md +32 -0
  19. package/skills/blender-camera-blocking/assets/Blockstage-0.3.0.zip +0 -0
  20. package/skills/blender-camera-blocking/references/addon-installation.md +17 -0
  21. package/skills/blender-camera-blocking/references/character-staging.md +19 -0
  22. package/skills/blender-camera-blocking/references/layout-and-assets.md +19 -0
  23. package/skills/blender-camera-blocking/references/live-construction.md +7 -0
  24. package/skills/blender-camera-led-assembly/SKILL.md +48 -0
  25. package/skills/blender-camera-led-assembly/references/blender-mechanics.md +45 -0
  26. package/skills/blender-camera-led-assembly/references/camera-choreography.md +25 -0
  27. package/skills/blender-camera-led-assembly/references/case-template.md +23 -0
  28. package/skills/blender-camera-led-assembly/references/environment-assembly.md +27 -0
  29. package/skills/blender-camera-led-assembly/references/product-assembly.md +25 -0
  30. package/skills/blender-camera-led-assembly/references/validation-delivery.md +33 -0
  31. package/skills/blender-destruction/SKILL.md +24 -0
  32. package/skills/blender-destruction/references/assembly.md +23 -0
  33. package/skills/blender-destruction/references/delivery.md +29 -0
  34. package/skills/blender-destruction/references/simulation.md +25 -0
  35. package/skills/blender-generation/SKILL.md +139 -0
  36. package/skills/blender-greybox/SKILL.md +103 -0
  37. package/skills/blender-hdri/SKILL.md +133 -0
  38. package/skills/blender-lighting-camera/SKILL.md +217 -0
  39. package/skills/blender-lookdev/SKILL.md +99 -0
  40. package/skills/blender-modeling/SKILL.md +181 -0
  41. package/skills/blender-pbr/SKILL.md +155 -0
  42. package/skills/blender-scene/SKILL.md +70 -5
  43. package/skills/blender-scene-spec/SKILL.md +177 -0
  44. package/skills/blender-stylized-materials/SKILL.md +34 -0
  45. package/skills/blender-stylized-materials/assets/BUNDLE.md +19 -0
  46. package/skills/blender-stylized-materials/assets/cube-demo.blend +0 -0
  47. package/skills/blender-stylized-materials/assets/cube-preview.png +0 -0
  48. package/skills/blender-stylized-materials/assets/materials.blend +0 -0
  49. package/skills/blender-stylized-materials/assets/previews/BACKGROUND.png +0 -0
  50. package/skills/blender-stylized-materials/assets/previews/CANVAS.png +0 -0
  51. package/skills/blender-stylized-materials/assets/previews/CEL.png +0 -0
  52. package/skills/blender-stylized-materials/assets/previews/COMIC_PULSE.png +0 -0
  53. package/skills/blender-stylized-materials/assets/previews/DRYBRUSH.png +0 -0
  54. package/skills/blender-stylized-materials/assets/previews/HALFTONE.png +0 -0
  55. package/skills/blender-stylized-materials/assets/previews/MANGA.png +0 -0
  56. package/skills/blender-stylized-materials/assets/previews/METAL.png +0 -0
  57. package/skills/blender-stylized-materials/assets/previews/NEON.png +0 -0
  58. package/skills/blender-stylized-materials/assets/previews/PAINT.png +0 -0
  59. package/skills/blender-stylized-materials/assets/previews/WATERCOLOR.png +0 -0
  60. package/skills/blender-stylized-materials/assets/stylized-materials-addon.zip +0 -0
  61. package/skills/blender-stylized-materials/references/material-engineering.md +31 -0
  62. package/skills/blender-stylized-materials/references/ready-to-use-bundle.md +23 -0
  63. package/skills/blender-stylized-materials/references/scene-delivery.md +27 -0
  64. package/skills/blender-stylized-materials/references/visual-library.md +21 -0
  65. package/skills/blender-volatile/SKILL.md +150 -0
  66. package/skills/use-blender/SKILL.md +8 -7
  67. package/skills/use-blender/references/installation.md +22 -22
  68. package/skills/use-blender/references/verification.md +11 -9
  69. package/addon/higgsfield_use_blender/__init__.py +0 -39
  70. package/addon/higgsfield_use_blender/bridge.py +0 -197
  71. package/scripts/install_addon.py +0 -18
  72. package/scripts/launch.py +0 -10
package/README.md CHANGED
@@ -1,47 +1,29 @@
1
1
  # Higgsfield use Blender
2
2
 
3
- An independent local MCP project for editing native Blender scenes. No WebSocket, Cloudflare Worker, R2 bucket or cloud bridge account is required.
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.
4
4
 
5
5
  ```text
6
- Desktop MCP client ── stdio ── Node MCP server
7
- │
8
- authenticated HTTP on 127.0.0.1
9
- │
10
- Blender Python add-on
11
- │
12
- main-thread timer → bpy
6
+ Desktop MCP client → stdio → Node MCP → process pipes → Blender --background → bpy
13
7
  ```
14
8
 
15
- Install the public [fnf-blender-mcp](https://www.npmjs.com/package/fnf-blender-mcp) package, published by `arsu_higgsfield_ai`. No source checkout or build is needed for installation.
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.
16
10
 
17
- ## Quick start
11
+ ## Setup
18
12
 
19
- Requires Node.js 24+ with npm and Blender 4.2+. Blender supplies the add-on's Python runtime; a separate Python installation is only needed for development tests.
20
-
21
- Install into a persistent user directory (macOS/Linux):
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.
22
14
 
23
15
  ```sh
24
- bridgeDir="$HOME/.higgsfield/blender-mcp"
25
- npm install --prefix "$bridgeDir" --registry=https://registry.npmjs.org/ fnf-blender-mcp@0.1.0
26
- bridgeCli="$bridgeDir/node_modules/fnf-blender-mcp/dist/cli.js"
27
- node "$bridgeCli" launch --blender "/absolute/path/to/blender"
28
- node "$bridgeCli" doctor
29
- node "$bridgeCli" config --format json
16
+ npm view fnf-blender-mcp@latest version --registry=https://registry.npmjs.org/
17
+ blenderDir="$HOME/.higgsfield/blender-mcp"
18
+ npm install --prefix "$blenderDir" --registry=https://registry.npmjs.org/ fnf-blender-mcp@latest
19
+ blenderCli="$blenderDir/node_modules/fnf-blender-mcp/dist/cli.js"
20
+ node "$blenderCli" doctor --blender "/absolute/path/to/blender"
21
+ node "$blenderCli" config --blender "/absolute/path/to/blender" --format json
30
22
  ```
31
23
 
32
- For Windows PowerShell, use `$bridgeDir = Join-Path $env:LOCALAPPDATA 'Higgsfield/blender-mcp'`, install with `npm.cmd install --prefix $bridgeDir --registry=https://registry.npmjs.org/ fnf-blender-mcp@0.1.0`, then set `$bridgeCli = Join-Path $bridgeDir 'node_modules/fnf-blender-mcp/dist/cli.js'` and invoke `node $bridgeCli` with the commands above. The [installation skill](skills/use-blender/references/installation.md) contains the full setup procedure.
33
-
34
- `launch` opens a **new** Blender process with the bundled bridge. It does not attach to an existing process or change saved preferences. Wait for startup before running `doctor`. Use the actual executable, such as `Blender.app/Contents/MacOS/Blender` on macOS or `blender.exe` on Windows.
35
-
36
- For an existing installation and automatic startup:
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.
37
25
 
38
- ```sh
39
- node "$bridgeCli" install-addon --blender "/absolute/path/to/blender"
40
- ```
41
-
42
- Enable **Higgsfield use Blender** in Blender's Preferences → Add-ons, then save preferences if desired. The installer only copies the add-on into that version's user scripts directory. It never saves or resets preferences, and refuses to overwrite an unrecognized add-on directory. An already-running instance may need refreshing or restarting; preserve unsaved work.
43
-
44
- Merge the generated JSON entry into the desktop client's MCP configuration. For Codex use `node "$bridgeCli" config --format toml`. This helper prints configuration; it does not register or enable a client connection. Both formats use absolute Node and server paths. Keep the installed package at that persistent location. Refresh the client's MCP connection, then call `bl_health` and `bl_get_scene_summary` from the conversation. A shell `doctor` success alone does not establish conversation tool availability.
26
+ 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.
45
27
 
46
28
  ## Tools
47
29
 
@@ -52,43 +34,39 @@ Merge the generated JSON entry into the desktop client's MCP configuration. For
52
34
  | Camera/light | `bl_add_camera`, `bl_set_active_camera`, `bl_add_light` |
53
35
  | Animation | `bl_set_frame`, `bl_insert_keyframe` |
54
36
  | Files | `bl_save_project`, `bl_open_project` |
55
- | Evidence | `bl_screenshot`, `bl_render` |
37
+ | Evidence | `bl_render` |
56
38
  | Python/status | `bl_execute`, `bl_job_status` |
57
- | Offline guidance | `bl_get_skill` |
39
+ | Offline guidance | `bl_list_skills`, `bl_get_skill`, `bl_get_skill_asset` |
58
40
 
59
- Start with `bl_get_skill(name: "blender-scene")`. It routes to modeling, materials, lighting/camera and animation guidance. Use `bl_execute` for bpy operations beyond the typed tools; assign a JSON-compatible value to `result`. stdout and stderr are returned with a 64 KiB cap each. Results are limited to 4 MiB; return a file path for larger data.
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.
60
42
 
61
- Screenshots return inline MCP images without cloud uploads and need a VIEW_3D area. Camera renders write a local PNG and include a preview for files up to 4 MiB. Render overrides are restored afterward; an explicit sample override currently requires Cycles. Saving or rendering over an existing file requires `overwrite: true`. Opening a project refuses unsaved changes unless `discard_unsaved: true`.
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.
62
44
 
63
- This package does not bundle cloud generation, offline Blender API/manual search, or the original connector's model-generation and motion-import handlers. Use a separate Higgsfield MCP for generation, download finished assets, and import an absolute local path. Local Python has the full permissions of the Blender process; it is not a sandbox.
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.
64
46
 
65
- ## Connection and timeout behavior
47
+ ## Session lifecycle
66
48
 
67
- - The add-on binds an ephemeral loopback port and generates a random bearer token. Discovery files live in `~/.higgsfield/blender/bridge-<pid>.json`, with owner-only file permissions on POSIX. Windows uses the user's profile-directory ACLs.
68
- - Browser-origin requests, incorrect Host headers and missing/incorrect tokens are rejected. Do not expose the endpoint or share its token.
69
- - The server refuses ambiguous multi-instance discovery. Set `BLENDER_MCP_PID` in the MCP server environment to select the intended instance.
70
- - Override `BLENDER_MCP_RUNTIME_DIR` on **both** the Blender process and MCP process when a custom directory is needed. `config` does not include these environment overrides automatically.
71
- - HTTP only queues work. A persistent Blender timer executes Python on the main thread. Rendering or long scripts can block Blender's UI.
72
- - Commands are accepted once and receive a job ID. Pending jobs expire before execution after their deadline. A running script cannot be safely interrupted; timeout does not mean cancellation or rollback.
73
- - After a timeout, use `bl_job_status` on the original PID before retrying. A script exception can leave partial changes. If submission failed before the job ID was received, inspect the scene first.
74
- - Up to 128 job records are retained in memory, with old completed jobs evicted as new jobs arrive. Restarting Blender loses history. A client-side MCP timeout may occur before the tool returns its job ID; set tool timeouts above 300 seconds for long renders and inspect state before retrying.
49
+ 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.
75
50
 
76
- ## Development and verification
51
+ 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.
77
52
 
78
- From a source checkout, run `npm ci` first. Development checks:
53
+ ## Development and validation
54
+
55
+ From the repository root:
79
56
 
80
57
  ```sh
58
+ cd blender
59
+ npm ci
81
60
  npm test
82
61
  npm run typecheck
83
62
  npm run test:package
63
+ BLENDER_EXECUTABLE=/absolute/path/to/blender npm run test:live
84
64
  ```
85
65
 
86
- The automated tests cover a real stdio MCP subprocess talking to the real Python HTTP bridge, authenticated discovery, multiple instances, execution errors, result images, input validation, queue overload, expiration, timeouts and status recovery. A minimal bpy fixture supplies metadata in those tests. Every typed Python script is syntax-compiled. `test:package` builds a local `.tgz`, installs it in an isolated directory, and checks the shipped runtime, skills and setup files; it does not publish anything.
87
-
88
- **These tests do not prove live Blender behavior.** Blender was not found on the development Mac, so real geometry, viewport rendering, installer behavior inside Blender and Windows/Linux integration remain unverified. For a live check, run `doctor`, inspect a disposable scene, create a primitive, set a material and camera, render/view a small PNG, save/reopen a temporary `.blend`, and confirm unsaved-file guards. Never use an existing unsaved project as the test fixture.
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.
89
67
 
90
- ## Slash command
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.
91
69
 
92
- The matching `/use-blender` bundle is added to `fnf-mcp-server` under `src/tools/preset-instructions/resources/commands/use-blender`. It returns installation and verification instructions before any remote preset lookup. The standalone source lives in `skills/use-blender`; keep their bodies synchronized when editing setup instructions. The server command adds a `title` frontmatter field for its catalog.
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.
93
71
 
94
- See [UPSTREAM.md](UPSTREAM.md) for provenance and [LICENSE](LICENSE) for the MIT license.
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 ADDED
@@ -0,0 +1,11 @@
1
+ # Release fnf-blender-mcp
2
+
3
+ Run commands from this `blender/` directory. Its package and lockfile are independent of After Effects.
4
+
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.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
+ 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
+
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/UPSTREAM.md CHANGED
@@ -1 +1 @@
1
- Core Blender tool Python bodies and schemas are adapted from Higgsfield fnf-adobe-mcp-connector, worker/bl-tools.ts and worker/bl-schemas.ts, revision fd5359a8c72f6ce30e62e5b500ffaf08f649bb84 (MIT per upstream package.json). Cloud routing, R2 image storage, cloud generation and remote documentation commands are omitted. The local transport, add-on, installer and setup skill are independent implementations.
1
+ Core Blender tool Python bodies and schemas are adapted from Higgsfield fnf-adobe-mcp-connector, worker/bl-tools.ts and worker/bl-schemas.ts, revision fd5359a8c72f6ce30e62e5b500ffaf08f649bb84 (MIT per upstream package.json). Cloud routing, R2 image storage, cloud generation and remote documentation commands are omitted. The background process transport, command runner and setup skill are independent implementations.
package/dist/cli.js CHANGED
@@ -1,70 +1,58 @@
1
1
  #!/usr/bin/env node
2
- import { spawn } from "node:child_process";
3
2
  import { fileURLToPath } from "node:url";
4
- import { stat } from "node:fs/promises";
5
- import { isAbsolute } from "node:path";
6
- import { BlenderTransport, discover } from "./transport.js";
7
- const root = new URL("../", import.meta.url);
3
+ import { BlenderTransport, resolveExecutable } from "./transport.js";
8
4
  const usage = `Higgsfield use Blender
9
- fnf-blender doctor Discover bridges and verify live bpy execution
10
- fnf-blender config [--format json|toml] Print local MCP configuration
11
- fnf-blender launch --blender PATH Open Blender with the bundled bridge
12
- fnf-blender install-addon --blender PATH Install the add-on for that Blender version
13
- fnf-blender serve Run the stdio MCP server
5
+ fnf-blender doctor [--blender PATH] Verify a new background Blender process
6
+ fnf-blender config [--blender PATH] [--format json|toml] Print local MCP configuration
7
+ fnf-blender serve Run the stdio MCP server
14
8
 
15
- Requires Node.js 24+ and Blender 4.2+. BLENDER_MCP_PID selects an instance.
16
- BLENDER_MCP_RUNTIME_DIR overrides local bridge discovery on both processes.
17
- No WebSocket or cloud account is required. npm package: fnf-blender-mcp.`;
9
+ Requires Node.js 24+ and Blender 4.2+. BLENDER_EXECUTABLE selects the executable.
10
+ Each MCP session owns a background Blender process. Save .blend files before reconnecting.
11
+ No add-on, HTTP listener, WebSocket or open Blender window is required.`;
18
12
  async function main() {
19
13
  const [command = "help", ...args] = process.argv.slice(2);
20
14
  if (["help", "--help", "-h"].includes(command)) {
21
15
  console.log(usage);
22
16
  return;
23
17
  }
24
- if (command === "serve") {
18
+ if (command === "serve" && args.length === 0) {
25
19
  await import("./index.js");
26
20
  return;
27
21
  }
28
- if (command === "config") {
29
- if (args.length && (args[0] !== "--format" || args.length !== 2 || !["json", "toml"].includes(args[1])))
22
+ if (!["config", "doctor"].includes(command))
23
+ throw new Error(usage);
24
+ let executable, format = "json";
25
+ const seen = new Set();
26
+ for (let index = 0; index < args.length; index += 2) {
27
+ const key = args[index], value = args[index + 1];
28
+ if (!value || seen.has(key))
30
29
  throw new Error(usage);
31
- const entry = { command: process.execPath, args: [fileURLToPath(new URL("dist/index.js", root))] };
32
- if (args[1] === "toml") {
33
- console.log(`[mcp_servers.higgsfield-use-blender]\ncommand = ${JSON.stringify(entry.command)}\nargs = ${JSON.stringify(entry.args)}`);
34
- }
30
+ seen.add(key);
31
+ if (key === "--blender")
32
+ executable = value;
33
+ else if (key === "--format" && command === "config" && ["json", "toml"].includes(value))
34
+ format = value;
35
+ else
36
+ throw new Error(usage);
37
+ }
38
+ executable = await resolveExecutable(executable);
39
+ if (command === "config") {
40
+ const entry = { command: process.execPath, args: [fileURLToPath(new URL("./index.js", import.meta.url))], env: { BLENDER_EXECUTABLE: executable } };
41
+ if (format === "toml")
42
+ console.log(`[mcp_servers.higgsfield-use-blender]\ncommand = ${JSON.stringify(entry.command)}\nargs = ${JSON.stringify(entry.args)}\n[mcp_servers.higgsfield-use-blender.env]\nBLENDER_EXECUTABLE = ${JSON.stringify(executable)}`);
35
43
  else
36
44
  console.log(JSON.stringify({ mcpServers: { "higgsfield-use-blender": entry } }, null, 2));
37
45
  return;
38
46
  }
39
- if (command === "doctor") {
40
- const connections = await discover();
41
- console.log(JSON.stringify({ node: process.version, bridges: connections.map(({ pid, port }) => ({ pid, port })) }));
42
- const job = await new BlenderTransport().execute("import bpy\nresult = {'version': bpy.app.version_string, 'file': bpy.data.filepath, 'scene': bpy.context.scene.name}", 5);
47
+ const transport = new BlenderTransport({ executable });
48
+ try {
49
+ const job = await transport.execute("import bpy, os\nresult = {'version': bpy.app.version_string, 'background': bpy.app.background, 'pid': os.getpid(), 'file': bpy.data.filepath}", 15);
43
50
  if (!job.ok)
44
51
  throw new Error(job.error || "Blender execution failed");
45
- console.log(JSON.stringify(job.result));
46
- return;
52
+ console.log(JSON.stringify({ executable, ...job }));
47
53
  }
48
- if (command === "install-addon" || command === "launch") {
49
- if (args[0] !== "--blender" || args.length !== 2)
50
- throw new Error("Pass --blender with the absolute Blender executable path.");
51
- const executable = args[1];
52
- if (!isAbsolute(executable) || !(await stat(executable)).isFile())
53
- throw new Error("--blender must point to an absolute executable file, not the .app directory.");
54
- const script = fileURLToPath(new URL(command === "launch" ? "scripts/launch.py" : "scripts/install_addon.py", root));
55
- const launchArgs = command === "launch" ? ["--python", script] : ["--background", "--factory-startup", "--python-exit-code", "1", "--python", script];
56
- const child = spawn(executable, launchArgs, { stdio: command === "launch" ? "ignore" : "inherit", detached: command === "launch" });
57
- await new Promise((resolve, reject) => {
58
- child.once("error", reject);
59
- if (command === "launch")
60
- child.once("spawn", () => { child.unref(); resolve(); });
61
- else
62
- child.once("exit", code => code === 0 ? resolve() : reject(new Error(`Blender installer exited ${code}`)));
63
- });
64
- if (command === "launch")
65
- console.log("Blender launched with the bridge bootstrap. Run doctor after startup to verify the connection.");
66
- return;
54
+ finally {
55
+ await transport.close();
67
56
  }
68
- throw new Error(usage);
69
57
  }
70
58
  main().catch(error => { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; });
package/dist/index.js CHANGED
@@ -1,4 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
3
3
  import { createServer } from "./server.js";
4
- await createServer().connect(new StdioServerTransport());
4
+ import { BlenderTransport } from "./transport.js";
5
+ const blender = new BlenderTransport();
6
+ const server = createServer(blender);
7
+ server.server.onclose = () => { void blender.close(); };
8
+ const shutdown = async () => { await blender.close(); await server.close(); };
9
+ process.once("SIGINT", () => { void shutdown(); });
10
+ process.once("SIGTERM", () => { void shutdown(); });
11
+ await server.connect(new StdioServerTransport());
package/dist/schemas.d.ts CHANGED
@@ -62,6 +62,7 @@ export declare const setFrameShape: {
62
62
  };
63
63
  export declare const renderShape: {
64
64
  output_path: z.ZodString;
65
+ overwrite: z.ZodDefault<z.ZodBoolean>;
65
66
  resolution: z.ZodOptional<z.ZodTuple<[z.ZodNumber, z.ZodNumber], null>>;
66
67
  engine: z.ZodOptional<z.ZodEnum<{
67
68
  BLENDER_EEVEE_NEXT: "BLENDER_EEVEE_NEXT";
@@ -70,15 +71,6 @@ export declare const renderShape: {
70
71
  }>>;
71
72
  samples: z.ZodOptional<z.ZodNumber>;
72
73
  };
73
- export declare const screenshotShape: {
74
- max_size: z.ZodOptional<z.ZodNumber>;
75
- shading: z.ZodOptional<z.ZodEnum<{
76
- SOLID: "SOLID";
77
- MATERIAL: "MATERIAL";
78
- RENDERED: "RENDERED";
79
- WIREFRAME: "WIREFRAME";
80
- }>>;
81
- };
82
74
  export declare const getObjectShape: {
83
75
  name: z.ZodString;
84
76
  };
package/dist/schemas.js CHANGED
@@ -3,7 +3,7 @@ const vec3 = z.tuple([z.number(), z.number(), z.number()]);
3
3
  export const executeShape = {
4
4
  code: z
5
5
  .string().min(1).max(1_000_000)
6
- .describe("Python source executed inside the connected Blender with full `bpy` "
6
+ .describe("Python source executed inside this MCP session’s background Blender with full `bpy` "
7
7
  + "access. To return data, assign a JSON-serialisable value to a variable "
8
8
  + "named `result`."),
9
9
  };
@@ -57,7 +57,8 @@ export const setFrameShape = {
57
57
  frame: z.number().int().describe("Frame number to set as current."),
58
58
  };
59
59
  export const renderShape = {
60
- output_path: z.string().min(1).describe("Absolute path for the PNG. Default a temp file."),
60
+ output_path: z.string().min(1).describe("Absolute path for the PNG output."),
61
+ overwrite: z.boolean().default(false).describe("Allow replacing an existing output file."),
61
62
  resolution: z
62
63
  .tuple([z.number().int().min(1).max(16384), z.number().int().min(1).max(16384)])
63
64
  .optional()
@@ -66,18 +67,6 @@ export const renderShape = {
66
67
  .describe("Render engine. Default the scene's current engine."),
67
68
  samples: z.number().int().min(1).max(65536).optional().describe("Cycles sample count; requires the CYCLES engine."),
68
69
  };
69
- export const screenshotShape = {
70
- max_size: z
71
- .number()
72
- .int()
73
- .min(64).max(2048)
74
- .optional()
75
- .describe("Longest-edge pixel cap for the capture (keeps it under the 1MB message limit). Default 1280."),
76
- shading: z
77
- .enum(["SOLID", "MATERIAL", "RENDERED", "WIREFRAME"])
78
- .optional()
79
- .describe("Viewport shading to capture with. Default keeps the current viewport shading."),
80
- };
81
70
  export const getObjectShape = {
82
71
  name: z.string().describe("Exact object name to inspect."),
83
72
  };
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: "Local Blender control. Read bl_get_skill(blender-scene), then bl_health and bl_get_scene_summary before edits. Prefer typed tools; use bl_execute for other bpy operations. Commands can partially mutate before errors. On timeout, query bl_job_status; never blindly retry. Render and view results. Cloud generation is provided by a separate Higgsfield MCP, not this server.",
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);
@@ -48,7 +49,7 @@ export function createServer(transport = new BlenderTransport()) {
48
49
  });
49
50
  }
50
51
  server.registerTool("bl_job_status", {
51
- title: "Check Blender Job", description: "Check a previously accepted job after timeout without repeating its mutations. Select the original Blender PID when multiple instances are open. Completed results expire after 128 newer accepted jobs or Blender exits.",
52
+ title: "Check Blender Job", description: "Check a previously accepted job after timeout without repeating its mutations. Use the same MCP session. Results expire after 128 accepted jobs or reconnecting.",
52
53
  inputSchema: z.object({ job_id: z.string().regex(/^[a-f0-9]{32}$/) }).strict(),
53
54
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
54
55
  }, async ({ job_id }) => {
@@ -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
  }