feat(blender): agent control via mcp-for-blender (in-container, ssh stdio)

The MCP server (mcp-for-blender 2.1.1, frozen requirements) runs inside the Blender
container. Its add-on is vendored at upstream 41a18432 (MIT) and started by a
startup hook. scripts/blender-mcp carries the stdio over ssh + docker exec, so the
add-on socket, which runs arbitrary Python with no auth, stays on the container's
localhost with no published port. It also runs there because viewport screenshots
need a filesystem shared by server and Blender. Telemetry is off and safe mode is
on. The hook also defaults Cycles to OptiX on GPU 3, because safe mode forbids
agents from touching preferences.

Verified end to end from nh3-dev: 36 tools; a GPU render of an agent-built scene;
a viewport screenshot; and safe mode refusing 'import os'. Blender left down
(on demand).
This commit is contained in:
vh
2026-09-27 14:34:07 -07:00
parent 7ae7193c21
commit ac1cd29afa
8 changed files with 6442 additions and 6 deletions
+32
View File
@@ -0,0 +1,32 @@
# mcp-for-blender server deps, installed INSIDE the blender container at /work/.mcp-venv (scripts/blender-mcp setup).
# Frozen 2026-09-27 from a clean install of mcp-for-blender==2.1.1 on the image python (3.14).
annotated-types==0.8.0
anyio==4.15.1
attrs==26.1.0
certifi==2026.7.22
cffi==2.1.1
click==8.5.0
cryptography==50.0.1
h11==0.16.0
httpcore==1.0.9
httpx==0.28.1
httpx-sse==0.4.3
idna==3.20
jsonschema==4.26.0
jsonschema-specifications==2025.9.1
mcp==1.30.0
mcp-for-blender==2.1.1
pycparser==3.0
pydantic==2.13.5
pydantic-settings==2.15.0
pydantic_core==2.46.5
PyJWT==2.15.0
python-dotenv==1.2.3
python-multipart==0.0.32
referencing==0.37.0
rpds-py==2026.6.3
sse-starlette==3.4.11
starlette==1.7.0
typing-inspection==0.4.4
typing_extensions==4.16.0
uvicorn==0.54.0
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Siddharth Ahuja
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,97 @@
"""Fleet startup hook for the mcp-for-blender add-on (runs at every Blender launch, from the
user scripts/startup dir). Part of eshpfi-management stacks/blender; see its README.
The add-on (scripts/addons/blender_mcp.py, pinned upstream commit 41a18432) serves a socket that
runs arbitrary Python with NO authentication. The MCP server that talks to it runs INSIDE this
same container (/work/.mcp-venv), so the socket stays on the container's localhost and no port is
published. That same-container arrangement is also what makes viewport screenshots work: the MCP
server reads the image file Blender writes, which needs a shared filesystem.
This hook exists because the add-on is not listed in any saved user preferences. It enables the
add-on after the prefs have loaded, and makes sure its server is running.
Blender's stdout goes to /dev/null in this image, so every step is logged to
/config/.local/state/fleet_mcp.log.
"""
import os
import time
import traceback
import addon_utils
import bpy
MODULE, HOST, PORT = "blender_mcp", "localhost", 9876
LOG = "/config/.local/state/fleet_mcp.log"
def log(msg):
os.makedirs(os.path.dirname(LOG), exist_ok=True)
with open(LOG, "a") as f:
f.write(f"{time.strftime('%Y-%m-%d %H:%M:%S')} {msg}\n")
def _ensure_serving():
"""Timer: once Blender is fully up, enable the add-on and make sure its server is running on
HOST:PORT. Retries every second until it is.
⚠ The add-on is enabled HERE, not in register(). Startup scripts run before Blender loads
the user preferences, and loading them disables any add-on the prefs do not list: an early
enable was undone, taking the add-on's Scene properties with it, while the server object
kept answering "'Scene' object has no attribute 'blendermcp_use_polyhaven'" (found
2026-09-27)."""
try:
server = getattr(bpy.types, "blendermcp_server", None)
if not addon_utils.check(MODULE)[1]:
import blender_mcp # our object first, so the add-on's auto-start adopts it
if server is None:
bpy.types.blendermcp_server = blender_mcp.BlenderMCPServer(host=HOST, port=PORT)
addon_utils.enable(MODULE, default_set=True)
log(f"add-on enabled: {MODULE}")
return 1.0
import blender_mcp
if server is not None and server.host != HOST:
if server.running:
server.stop()
server = None
if server is None:
server = blender_mcp.BlenderMCPServer(host=HOST, port=PORT)
bpy.types.blendermcp_server = server
if not server.running:
server.start()
if server.running and hasattr(bpy.context.scene, "blendermcp_use_polyhaven"):
log(f"serving on {server.host}:{server.port}")
_gpu_by_default()
return None
log("not serving yet; retrying")
return 1.0
except Exception:
log("ensure_serving failed:\n" + traceback.format_exc())
return 2.0
def _gpu_by_default():
"""Point Cycles at GPU 3 through OptiX. Safe mode forbids agent code from touching
bpy.context.preferences, so a model cannot select the GPU itself: "'preferences' reached
through an unresolvable receiver" (found 2026-09-27). It CAN set scene.cycles.device, so the
current scene is set to GPU here too, and agents set it on scenes they create."""
try:
prefs = bpy.context.preferences.addons["cycles"].preferences
prefs.compute_device_type = "OPTIX"
prefs.refresh_devices()
for d in prefs.devices:
d.use = d.type == "OPTIX"
bpy.context.scene.cycles.device = "GPU"
log("cycles: OPTIX on " + ", ".join(d.name for d in prefs.devices if d.use))
except Exception:
log("cycles GPU default failed:\n" + traceback.format_exc())
def register():
if bpy.app.background: # headless renders never need the socket
return
bpy.app.timers.register(_ensure_serving, first_interval=2.0, persistent=True)
def unregister():
pass