fnf-blender-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Higgsfield
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,94 @@
1
+ # Higgsfield use Blender
2
+
3
+ An independent local MCP project for editing native Blender scenes. No WebSocket, Cloudflare Worker, R2 bucket or cloud bridge account is required.
4
+
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
13
+ ```
14
+
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.
16
+
17
+ ## Quick start
18
+
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):
22
+
23
+ ```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
30
+ ```
31
+
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:
37
+
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.
45
+
46
+ ## Tools
47
+
48
+ | Area | Tools |
49
+ | --- | --- |
50
+ | Inspect | `bl_health`, `bl_get_scene_summary`, `bl_get_object` |
51
+ | Scene | `bl_add_primitive`, `bl_delete_object`, `bl_set_transform`, `bl_set_material`, `bl_import_model` |
52
+ | Camera/light | `bl_add_camera`, `bl_set_active_camera`, `bl_add_light` |
53
+ | Animation | `bl_set_frame`, `bl_insert_keyframe` |
54
+ | Files | `bl_save_project`, `bl_open_project` |
55
+ | Evidence | `bl_screenshot`, `bl_render` |
56
+ | Python/status | `bl_execute`, `bl_job_status` |
57
+ | Offline guidance | `bl_get_skill` |
58
+
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.
60
+
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`.
62
+
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.
64
+
65
+ ## Connection and timeout behavior
66
+
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.
75
+
76
+ ## Development and verification
77
+
78
+ From a source checkout, run `npm ci` first. Development checks:
79
+
80
+ ```sh
81
+ npm test
82
+ npm run typecheck
83
+ npm run test:package
84
+ ```
85
+
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.
89
+
90
+ ## Slash command
91
+
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.
93
+
94
+ See [UPSTREAM.md](UPSTREAM.md) for provenance and [LICENSE](LICENSE) for the MIT license.
package/UPSTREAM.md ADDED
@@ -0,0 +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.
@@ -0,0 +1,39 @@
1
+ bl_info = {
2
+ "name": "Higgsfield use Blender",
3
+ "author": "Higgsfield",
4
+ "version": (0, 1, 0),
5
+ "blender": (4, 2, 0),
6
+ "category": "Interface",
7
+ "description": "Local MCP control over authenticated loopback HTTP",
8
+ }
9
+
10
+ import bpy
11
+ import atexit
12
+ from .bridge import Bridge
13
+
14
+ _bridge = None
15
+
16
+
17
+ def _pump():
18
+ return _bridge.pump() if _bridge else None
19
+
20
+
21
+ def register():
22
+ global _bridge
23
+ if _bridge:
24
+ return
25
+ bridge = Bridge(namespace=lambda: {"bpy": bpy})
26
+ bridge.start()
27
+ _bridge = bridge
28
+ atexit.register(bridge.stop)
29
+ bpy.app.timers.register(_pump, first_interval=0.05, persistent=True)
30
+
31
+
32
+ def unregister():
33
+ global _bridge
34
+ if bpy.app.timers.is_registered(_pump):
35
+ bpy.app.timers.unregister(_pump)
36
+ if _bridge:
37
+ atexit.unregister(_bridge.stop)
38
+ _bridge.stop()
39
+ _bridge = None
@@ -0,0 +1,197 @@
1
+ """Authenticated loopback transport; Blender data is accessed only by pump()."""
2
+
3
+ import contextlib
4
+ import hmac
5
+ import io
6
+ import json
7
+ import os
8
+ from pathlib import Path
9
+ import queue
10
+ import secrets
11
+ import threading
12
+ import time
13
+ import traceback
14
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
15
+
16
+ PROTOCOL = "higgsfield-blender/1"
17
+ MAX_BODY = 1_100_000
18
+ MAX_RESULT = 4 * 1024 * 1024
19
+
20
+
21
+ class CappedOutput(io.StringIO):
22
+ def write(self, text):
23
+ remaining = max(0, 65536 - self.tell())
24
+ super().write(text[:remaining])
25
+ return len(text)
26
+
27
+
28
+ class Job:
29
+ def __init__(self, code, timeout):
30
+ self.id = secrets.token_hex(16)
31
+ self.code = code
32
+ self.deadline = time.monotonic() + timeout
33
+ self.state = "queued"
34
+ self.response = None
35
+ self.completed = threading.Event()
36
+
37
+
38
+ class Bridge:
39
+ def __init__(self, runtime_dir=None, namespace=None):
40
+ self.directory = Path(runtime_dir or os.environ.get("BLENDER_MCP_RUNTIME_DIR", "") or Path.home() / ".higgsfield" / "blender")
41
+ self.namespace = namespace or (lambda: {})
42
+ self.token = secrets.token_hex(32)
43
+ self.jobs = {}
44
+ self.pending = queue.Queue(maxsize=32)
45
+ self.lock = threading.Lock()
46
+ self.server = None
47
+ self.discovery = self.directory / ("bridge-%s.json" % os.getpid())
48
+
49
+ def start(self):
50
+ self.directory.mkdir(parents=True, exist_ok=True, mode=0o700)
51
+ bridge = self
52
+
53
+ class Handler(BaseHTTPRequestHandler):
54
+ def log_message(self, *_args):
55
+ pass
56
+
57
+ def setup(self):
58
+ super().setup()
59
+ self.connection.settimeout(10)
60
+
61
+ def send_json(self, status, value):
62
+ data = json.dumps(value, allow_nan=False).encode("utf-8")
63
+ self.send_response(status)
64
+ self.send_header("Content-Type", "application/json")
65
+ self.send_header("Content-Length", str(len(data)))
66
+ self.send_header("Cache-Control", "no-store")
67
+ self.end_headers()
68
+ try:
69
+ self.wfile.write(data)
70
+ except (BrokenPipeError, ConnectionResetError):
71
+ pass
72
+
73
+ def authorized(self):
74
+ expected_host = "127.0.0.1:%s" % self.server.server_port
75
+ if self.headers.get("Origin") is not None or self.headers.get("Host") != expected_host:
76
+ self.send_json(403, {"error": "Local clients only"})
77
+ return False
78
+ expected = "Bearer " + bridge.token
79
+ if not hmac.compare_digest(self.headers.get("Authorization", "").encode(), expected.encode()):
80
+ self.send_json(401, {"error": "Invalid bridge token"})
81
+ return False
82
+ return True
83
+
84
+ def do_GET(self):
85
+ if not self.authorized():
86
+ return
87
+ if self.path == "/health":
88
+ self.send_json(200, {"protocol": PROTOCOL, "pid": os.getpid()})
89
+ return
90
+ if self.path.startswith("/jobs/"):
91
+ with bridge.lock:
92
+ job = bridge.jobs.get(self.path[len("/jobs/"):])
93
+ value = bridge.snapshot(job) if job else None
94
+ self.send_json(200 if value else 404, value or {"error": "Unknown or expired job"})
95
+ return
96
+ self.send_json(404, {"error": "Unknown endpoint"})
97
+
98
+ def do_POST(self):
99
+ if not self.authorized():
100
+ return
101
+ if self.path != "/execute":
102
+ self.send_json(404, {"error": "Unknown endpoint"})
103
+ return
104
+ if self.headers.get("Content-Type", "").split(";")[0] != "application/json":
105
+ self.send_json(415, {"error": "Expected application/json"})
106
+ return
107
+ try:
108
+ length = int(self.headers.get("Content-Length", "0"))
109
+ if not 0 < length <= MAX_BODY:
110
+ raise ValueError("Request body must be 1..1100000 bytes")
111
+ body = json.loads(self.rfile.read(length))
112
+ code = body.get("code")
113
+ timeout = body.get("timeout_seconds", 120)
114
+ if not isinstance(code, str) or not code or len(code) > 1_000_000:
115
+ raise ValueError("code must be a non-empty string up to 1000000 characters")
116
+ if isinstance(timeout, bool) or not isinstance(timeout, (int, float)) or not 1 <= timeout <= 600:
117
+ raise ValueError("timeout_seconds must be 1..600")
118
+ except (ValueError, AttributeError, UnicodeError) as exc:
119
+ self.send_json(400, {"error": str(exc)})
120
+ return
121
+ with bridge.lock:
122
+ if len(bridge.jobs) >= 128:
123
+ for job_id, old in list(bridge.jobs.items()):
124
+ if old.completed.is_set():
125
+ del bridge.jobs[job_id]
126
+ break
127
+ job = Job(code, timeout)
128
+ try:
129
+ bridge.pending.put_nowait(job)
130
+ except queue.Full:
131
+ self.send_json(503, {"error": "Execution queue is full; request was not accepted"})
132
+ return
133
+ bridge.jobs[job.id] = job
134
+ self.send_json(202, {"job_id": job.id, "state": "queued"})
135
+
136
+ class Server(ThreadingHTTPServer):
137
+ daemon_threads = True
138
+
139
+ self.server = Server(("127.0.0.1", 0), Handler)
140
+ record = {"protocol": PROTOCOL, "pid": os.getpid(), "port": self.server.server_port, "token": self.token}
141
+ try:
142
+ fd = os.open(self.discovery, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
143
+ with os.fdopen(fd, "w") as handle:
144
+ json.dump(record, handle)
145
+ except Exception:
146
+ self.server.server_close()
147
+ self.server = None
148
+ raise
149
+ self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
150
+ self.thread.start()
151
+
152
+ @staticmethod
153
+ def snapshot(job):
154
+ return {"job_id": job.id, "state": job.state, **(job.response or {})}
155
+
156
+ def pump(self):
157
+ try:
158
+ job = self.pending.get_nowait()
159
+ except queue.Empty:
160
+ return 0.05
161
+ with self.lock:
162
+ if time.monotonic() > job.deadline:
163
+ job.state = "expired"
164
+ job.response = {"ok": False, "error": "Expired before execution; no code ran"}
165
+ job.completed.set()
166
+ return 0.05
167
+ job.state = "running"
168
+ stdout, stderr = CappedOutput(), CappedOutput()
169
+ try:
170
+ namespace = {"__name__": "__blender_mcp__", **self.namespace()}
171
+ with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
172
+ exec(compile(job.code, "<blender-mcp>", "exec"), namespace)
173
+ response = {"ok": True, "result": namespace.get("result"), "stdout": stdout.getvalue(), "stderr": stderr.getvalue()}
174
+ encoded = json.dumps(response, allow_nan=False)
175
+ if len(encoded.encode("utf-8")) > MAX_RESULT:
176
+ raise ValueError("Result exceeds 4 MiB; return a file path or smaller result")
177
+ except BaseException:
178
+ response = {"ok": False, "error": traceback.format_exc(), "stdout": stdout.getvalue(), "stderr": stderr.getvalue()}
179
+ with self.lock:
180
+ job.response = response
181
+ job.state = "completed"
182
+ job.code = ""
183
+ job.completed.set()
184
+ return 0.05
185
+
186
+ def stop(self):
187
+ if self.server:
188
+ self.server.shutdown()
189
+ self.server.server_close()
190
+ self.thread.join(timeout=2)
191
+ self.server = None
192
+ try:
193
+ record = json.loads(self.discovery.read_text())
194
+ if record.get("token") == self.token:
195
+ self.discovery.unlink()
196
+ except (FileNotFoundError, ValueError):
197
+ pass
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from "node:child_process";
3
+ 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);
8
+ 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
14
+
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.`;
18
+ async function main() {
19
+ const [command = "help", ...args] = process.argv.slice(2);
20
+ if (["help", "--help", "-h"].includes(command)) {
21
+ console.log(usage);
22
+ return;
23
+ }
24
+ if (command === "serve") {
25
+ await import("./index.js");
26
+ return;
27
+ }
28
+ if (command === "config") {
29
+ if (args.length && (args[0] !== "--format" || args.length !== 2 || !["json", "toml"].includes(args[1])))
30
+ 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
+ }
35
+ else
36
+ console.log(JSON.stringify({ mcpServers: { "higgsfield-use-blender": entry } }, null, 2));
37
+ return;
38
+ }
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);
43
+ if (!job.ok)
44
+ throw new Error(job.error || "Blender execution failed");
45
+ console.log(JSON.stringify(job.result));
46
+ return;
47
+ }
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;
67
+ }
68
+ throw new Error(usage);
69
+ }
70
+ main().catch(error => { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; });
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
3
+ import { createServer } from "./server.js";
4
+ await createServer().connect(new StdioServerTransport());
@@ -0,0 +1,84 @@
1
+ import { z } from "zod";
2
+ export declare const executeShape: {
3
+ code: z.ZodString;
4
+ };
5
+ export declare const sceneSummaryShape: {};
6
+ export declare const addPrimitiveShape: {
7
+ kind: z.ZodEnum<{
8
+ cube: "cube";
9
+ uv_sphere: "uv_sphere";
10
+ ico_sphere: "ico_sphere";
11
+ cylinder: "cylinder";
12
+ cone: "cone";
13
+ plane: "plane";
14
+ torus: "torus";
15
+ monkey: "monkey";
16
+ }>;
17
+ name: z.ZodOptional<z.ZodString>;
18
+ location: z.ZodOptional<z.ZodTuple<[z.ZodNumber, z.ZodNumber, z.ZodNumber], null>>;
19
+ };
20
+ export declare const deleteObjectShape: {
21
+ name: z.ZodString;
22
+ };
23
+ export declare const setTransformShape: {
24
+ name: z.ZodString;
25
+ location: z.ZodOptional<z.ZodTuple<[z.ZodNumber, z.ZodNumber, z.ZodNumber], null>>;
26
+ rotation_euler: z.ZodOptional<z.ZodTuple<[z.ZodNumber, z.ZodNumber, z.ZodNumber], null>>;
27
+ scale: z.ZodOptional<z.ZodTuple<[z.ZodNumber, z.ZodNumber, z.ZodNumber], null>>;
28
+ };
29
+ export declare const setMaterialShape: {
30
+ object: z.ZodString;
31
+ color: z.ZodOptional<z.ZodTuple<[z.ZodNumber, z.ZodNumber, z.ZodNumber, z.ZodNumber], null>>;
32
+ metallic: z.ZodOptional<z.ZodNumber>;
33
+ roughness: z.ZodOptional<z.ZodNumber>;
34
+ material_name: z.ZodOptional<z.ZodString>;
35
+ };
36
+ export declare const importModelShape: {
37
+ path: z.ZodString;
38
+ location: z.ZodOptional<z.ZodTuple<[z.ZodNumber, z.ZodNumber, z.ZodNumber], null>>;
39
+ };
40
+ export declare const addLightShape: {
41
+ type: z.ZodEnum<{
42
+ POINT: "POINT";
43
+ SUN: "SUN";
44
+ SPOT: "SPOT";
45
+ AREA: "AREA";
46
+ }>;
47
+ location: z.ZodOptional<z.ZodTuple<[z.ZodNumber, z.ZodNumber, z.ZodNumber], null>>;
48
+ energy: z.ZodOptional<z.ZodNumber>;
49
+ name: z.ZodOptional<z.ZodString>;
50
+ };
51
+ export declare const addCameraShape: {
52
+ location: z.ZodOptional<z.ZodTuple<[z.ZodNumber, z.ZodNumber, z.ZodNumber], null>>;
53
+ rotation_euler: z.ZodOptional<z.ZodTuple<[z.ZodNumber, z.ZodNumber, z.ZodNumber], null>>;
54
+ set_active: z.ZodOptional<z.ZodBoolean>;
55
+ name: z.ZodOptional<z.ZodString>;
56
+ };
57
+ export declare const setActiveCameraShape: {
58
+ name: z.ZodString;
59
+ };
60
+ export declare const setFrameShape: {
61
+ frame: z.ZodNumber;
62
+ };
63
+ export declare const renderShape: {
64
+ output_path: z.ZodString;
65
+ resolution: z.ZodOptional<z.ZodTuple<[z.ZodNumber, z.ZodNumber], null>>;
66
+ engine: z.ZodOptional<z.ZodEnum<{
67
+ BLENDER_EEVEE_NEXT: "BLENDER_EEVEE_NEXT";
68
+ CYCLES: "CYCLES";
69
+ BLENDER_WORKBENCH: "BLENDER_WORKBENCH";
70
+ }>>;
71
+ samples: z.ZodOptional<z.ZodNumber>;
72
+ };
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
+ export declare const getObjectShape: {
83
+ name: z.ZodString;
84
+ };
@@ -0,0 +1,83 @@
1
+ import { z } from "zod";
2
+ const vec3 = z.tuple([z.number(), z.number(), z.number()]);
3
+ export const executeShape = {
4
+ code: z
5
+ .string().min(1).max(1_000_000)
6
+ .describe("Python source executed inside the connected Blender with full `bpy` "
7
+ + "access. To return data, assign a JSON-serialisable value to a variable "
8
+ + "named `result`."),
9
+ };
10
+ export const sceneSummaryShape = {};
11
+ export const addPrimitiveShape = {
12
+ kind: z
13
+ .enum(["cube", "uv_sphere", "ico_sphere", "cylinder", "cone", "plane", "torus", "monkey"])
14
+ .describe("Primitive type to add."),
15
+ name: z.string().optional().describe("Rename the created object to this."),
16
+ location: vec3.optional().describe("World position [x, y, z] in metres (Z up). Default [0,0,0]."),
17
+ };
18
+ export const deleteObjectShape = {
19
+ name: z.string().describe("Exact name of the object to delete."),
20
+ };
21
+ export const setTransformShape = {
22
+ name: z.string().describe("Exact object name to transform."),
23
+ location: vec3.optional().describe("Parent-relative location [x, y, z] in scene units."),
24
+ rotation_euler: vec3.optional().describe("Euler rotation [x, y, z] in RADIANS (XYZ)."),
25
+ scale: vec3.optional().describe("Scale factors [x, y, z] (1 = unchanged)."),
26
+ };
27
+ export const setMaterialShape = {
28
+ object: z.string().describe("Exact object name to receive the material."),
29
+ color: z
30
+ .tuple([z.number(), z.number(), z.number(), z.number()])
31
+ .optional()
32
+ .describe("Base colour RGBA, each 0-1. Default opaque mid-grey."),
33
+ metallic: z.number().min(0).max(1).optional().describe("Metallic 0-1."),
34
+ roughness: z.number().min(0).max(1).optional().describe("Roughness 0-1."),
35
+ material_name: z.string().optional().describe("Name for the created material."),
36
+ };
37
+ export const importModelShape = {
38
+ path: z.string().min(1).describe("Absolute local file path to import instead of a URL."),
39
+ location: vec3.optional().describe("Move the imported root to this world position."),
40
+ };
41
+ export const addLightShape = {
42
+ type: z.enum(["POINT", "SUN", "SPOT", "AREA"]).describe("Light type."),
43
+ location: vec3.optional().describe("World position [x, y, z]. Default [0,0,5]."),
44
+ energy: z.number().optional().describe("Light power/energy (watts). Default 1000 for POINT/SPOT/AREA, 5 for SUN."),
45
+ name: z.string().optional().describe("Rename the created light."),
46
+ };
47
+ export const addCameraShape = {
48
+ location: vec3.optional().describe("World position [x, y, z]. Default [7.36, -6.93, 4.96]."),
49
+ rotation_euler: vec3.optional().describe("Euler rotation [x, y, z] in RADIANS. Default a 3/4 view."),
50
+ set_active: z.boolean().optional().describe("Make this the active scene camera. Default true."),
51
+ name: z.string().optional().describe("Rename the created camera."),
52
+ };
53
+ export const setActiveCameraShape = {
54
+ name: z.string().describe("Exact camera object name to make active."),
55
+ };
56
+ export const setFrameShape = {
57
+ frame: z.number().int().describe("Frame number to set as current."),
58
+ };
59
+ export const renderShape = {
60
+ output_path: z.string().min(1).describe("Absolute path for the PNG. Default a temp file."),
61
+ resolution: z
62
+ .tuple([z.number().int().min(1).max(16384), z.number().int().min(1).max(16384)])
63
+ .optional()
64
+ .describe("Render [width, height] in pixels. Default the scene's current setting."),
65
+ engine: z.enum(["BLENDER_EEVEE_NEXT", "CYCLES", "BLENDER_WORKBENCH"]).optional()
66
+ .describe("Render engine. Default the scene's current engine."),
67
+ samples: z.number().int().min(1).max(65536).optional().describe("Cycles sample count; requires the CYCLES engine."),
68
+ };
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
+ export const getObjectShape = {
82
+ name: z.string().describe("Exact object name to inspect."),
83
+ };
@@ -0,0 +1,4 @@
1
+ import { McpServer, type CallToolResult } from "@modelcontextprotocol/server";
2
+ import { BlenderTransport, type JobResult } from "./transport.js";
3
+ export declare function formatResult(job: JobResult): Promise<CallToolResult>;
4
+ export declare function createServer(transport?: BlenderTransport): McpServer;