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
@@ -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