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:
@@ -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
|
||||
Reference in New Issue
Block a user