Files
esh-pfi-infrastructure/stacks/blender

blender

Blender 5.2.2 LTS on fv-ml1 GPU 3, on demand. Agents drive it; there is also a browser desktop to watch it or take over. Prime, 2026-09-27: "go ahead with gpu 3, both". He does not use Blender himself, so the agent side (MCP) is the primary interface.

Desktop https://10.251.50.54:3001 (self-signed cert). Basic auth: user blender, password secret get fv-ml1/blender-web-password.
Image lscr.io/linuxserver/blender:5.2.2-ls241@sha256:9216c77a… (Selkies 2.0 Wayland desktop, NVENC stream).
GPU GPU 3 only (NVIDIA_VISIBLE_DEVICES=3). About 270 MiB is held while the desktop runs.
Files /work → /tank/blender (projects, assets, renders; not backed up). /config → /opt/docker/data/blender (prefs, add-ons; restic). Files are owned by infra-ops (uid 1002), so agents can scp in and out.

⚠ On demand: GPU 3 is borrowed

GPU 3 is the fleet's reserve card for a full-size vLLM seat (servers/fv-ml1/README.md). Blender uses it only while in use:

ssh infra-ops@10.251.50.54 'cd /opt/docker/compose/blender && docker compose up -d'   # start
ssh infra-ops@10.251.50.54 'cd /opt/docker/compose/blender && docker compose down'    # stop, card back to 0

restart: "no", so a reboot never brings it back. When a big seat moves onto GPU 3, Blender stays down.

Headless rendering: scripts/blender-run (for scripts and CLI callers)

A one-shot docker run --rm of this image with Blender as the entrypoint. It needs no desktop and does not collide with the GUI container's up/down. --job DIR stages a local dir to /tank/blender/jobs/<name>/ and copies results back. Engines tested headless on 2026-09-28: Cycles GPU and CPU, EEVEE (EGL), Workbench. STL import is built in. --extensions also enables the pinned add-on set (STEP import and export among them; see "Extensions" below). Foot-guns and budget are in docs/fleettools/blender.md. First consumer: draupnir.

Headless rendering inside the running GUI container

ssh infra-ops@10.251.50.54 'docker exec -u abc blender blender -b /work/<file>.blend -E CYCLES -o /work/out/frame_#### -a -- --cycles-device OPTIX'

Run as -u abc, the image's user, mapped to uid 1002, so outputs land owned by infra-ops.

Acceptance (2026-09-27, 1356)

  • Cycles sees the card on both OptiX and CUDA: "NVIDIA RTX PRO 6000 Blackwell Max-Q Workstation Edition". The build ships kernel_sm_120.cubin plus OptiX PTX.
  • Self-test (/tank/blender/render_test.py): a subdivided glass monkey, 1920×1080, 1024 samples, 32 bounces, no denoise. OptiX 3.54 s against CPU 22.71 s (96 threads). That is n=1 per device: a functional check that the GPU is really used, not a benchmark. A trivial default-cube scene could not separate them (0.59 s against 0.65 s), which is why the self-test scene is heavy.
  • Web auth: no credentials gives 401, a wrong password 401, the right one 200.
  • Selkies: "Render node 1 encodes H264, AV1, H265 on nvenc"; the Wayland renderer runs GL on GPU 3.

Agent control (MCP): scripts/blender-mcp

The chosen server is mcp-for-blender (MIT, one maintainer, ~29k stars; researched by dvalin-smithy-dev 2026-09-27, thread 01M3JA61FTFW2ZPSD20MHF7RPJ, full note in dvalin-smithy research/blender-agent-drive-2026-09-27.md). Two halves:

  • The add-on is inside the running GUI Blender and serves a socket that executes arbitrary Python with no authentication. It is vendored at upstream commit 41a18432 (conf/scripts/addons/blender_mcp.py, MIT licence alongside) and started by conf/scripts/startup/fleet_mcp.py. The add-on only serves from a GUI Blender, never from blender -b, which is one reason the desktop exists.
  • The MCP server: mcp-for-blender==2.1.1, frozen in conf/mcp-requirements.txt and installed in /work/.mcp-venv inside the container (scripts/blender-mcp setup). Agents reach it as stdio over ssh infra-ops@fv-ml1 docker exec -i. It runs in the container for two reasons:
    1. No port is published. The socket stays on the container's localhost, so access means ssh + docker on fv-ml1.
    2. Viewport screenshots need a shared filesystem. Blender writes the image and the server reads it back. With the server on nh3-dev it failed ("Screenshot file was not created").

Settings: DISABLE_TELEMETRY=true and BLENDER_MCP_SAFE_MODE=1. Safe mode puts upstream's AST allowlist in front of execute_blender_code: bpy, bmesh, mathutils and pure stdlib only, and no os, open, eval or network. It guards against prompt injection from third-party asset text; it is not a sandbox.

For an agent

scripts/blender-mcp up          # GPU 3 is borrowed: start only when needed
scripts/blender-mcp status      # wait for "mcp add-on: answering" (~10-40 s)
claude mcp add blender -- /home/lkraven/development/eshpfi-management/scripts/blender-mcp   # PER TASK (Prime 2026-09-27): never user/project-wide
scripts/blender-mcp down        # when finished: the card goes back to 0
  • Always pass user_prompt. The tools require it; it is a short statement of the user's request.
  • Render on the GPU: set scene.cycles.device = 'GPU' on any scene you create. Safe mode forbids touching bpy.context.preferences, so the startup hook has already pointed Cycles at OptiX on GPU 3. The startup scene is already set to GPU.
  • Save everything under /work/… (= fv-ml1:/tank/blender), then scp it out.
  • Look before you report: get_viewport_screenshot returns an image, and a still render to /work is the real check.
  • The asset tools (Poly Haven etc.) reach the internet from Blender. The paid ones (Hyper3D, Hunyuan, Tripo, Sketchfab) need keys we do not have; leave them off.

Acceptance (2026-09-27, 1433)

An MCP client on nh3-dev → scripts/blender-mcp → the in-container server → the add-on:

  • initialize OK; 36 tools listed.
  • execute_blender_code built a gold metallic torus and rendered it with Cycles on the GPU to /work/_selftest/mcp-torus.png. The file landed, and I checked it by eye.
  • get_viewport_screenshot returned an image of the scene.
  • Negative control: import os was rejected by safe mode.
  • status pings the add-on itself. A TCP probe was useless because the port accepted while Blender was still loading.

⚠ Two traps found on the way, both fixed and commented where they live:

  • Enabling the add-on from a startup script gets undone when user prefs load. The hook enables it in a timer instead.
  • A pre-flight ssh without -n swallowed the MCP client's initialize, and the session hung at init.

Extensions: the pinned add-on set (2026-09-28)

Why: Prime's ruling of 2026-09-28 makes Blender a mandatory stage in draupnir's pipeline (requirements → functional shape in build123d → industrial design in Blender → print). draupnir asked for these add-ons (thread 01M3MQEGGR0N981645WNZ9DMF3). All come from extensions.blender.org, all are GPL, and all are pinned by version and archive sha256 in extensions.lock.

Add-on Version For Wheels
SurfacePsycho 0.10.4 NURBS/Bezier patch surfacing; STEP/IGES export (the route back to build123d). Alpha. cadquery-ocp-novtk 7.9.3.1 (cp313)
CAD Sketcher 0.32.1 Constraint-based precise profiles slvs 3.2 (cp313)
3D-Print Toolbox 1.4.1 Mesh cleanup (clean non-manifold) and checks before a mesh leaves Blender none
STEP Importer (Clonephaze) 1.2.1 STEP in, from vendor parts and functional shapes cascadio 0.0.18rc8 (abi3)
Bool Tool 2.1.0 Hard-surface booleans none
LoopTools 4.7.7 Mesh helpers none
MeasureIt 1.8.4 Dimensions drawn in the viewport (they show in screenshots) none
3MF Import/Export (Clonephaze) 2.7.7 3MF, which carries units (STL does not) none

Deliberately skipped (draupnir): ND, HardOps, BoxCutter (modal only, an agent cannot drive them), Quad Remesher (paid; the built-in QuadriFlow covers it), QRemeshify (not in the 5.2 catalogue).

How it is wired

stacks/blender/extensions.lock ──scripts/blender-extensions sync──▶ fv-ml1:/tank/blender-extensions/5.2/system
                                                                          │ mounted READ-ONLY as Blender's
                                                                          │ System repo (/blender/5.2/extensions/system)
                              ┌───────────────────────────────────────────┴──────────────────────────┐
                    GUI container (compose.yaml)                                  blender-run --extensions
     conf/scripts/startup/fleet_extensions.py enables all            the same file runs as --python ahead of the
     of it in a timer after the prefs load                           caller's args; any failure exits 1 first
  • Nobody installs from inside Blender. MCP safe mode blocks bpy.ops.extensions.*, addon_enable and register_class, and the repository is mounted read-only, so an agent can neither add an add-on nor alter one. Everything in the System repo is enabled; that directory holds exactly the lock.
  • Wheels (OCP, slvs, cascadio) are unpacked by Blender into the USER extensions dir, about 60 MB. In the GUI that is /config (= /opt/docker/data/blender, restic). In blender-run it is the container's /tmp, rebuilt every run, so concurrent runs share nothing.
  • scripts/blender-extensions sync downloads each archive (cached in /tank/blender-extensions/zips/), checks its sha256, installs it with Blender's own --command extension install-file into a staging dir, pre-warms and byte-compiles it, checks it enables read-only, and only then swaps it in. It refuses while the GUI container or a blender-run job is running, because they hold the old directory through the bind mount. scripts/blender-extensions status compares what is installed with the lock.
  • Bumping a version: edit the lock row, re-run the audit below on the new archive, stop the GUI (scripts/blender-mcp down), scripts/blender-extensions sync, run the acceptance probe.

Fleet-local fixes (in fleet_extensions.py, both paths)

  • SurfacePsycho view3d.sp_overwrite_segment_selection runs eval() on its string property. That walks straight past MCP safe mode: an agent's bpy.ops call passes the AST check, and the string inside it is never parsed. Nothing in the add-on calls that operator, so the hook swaps eval for ast.literal_eval, which keeps its documented use (a literal set of segment ids) and refuses code. Control (2026-09-28): unpatched, the payload [__import__('os').getpid()] ran and returned [1]; patched, it raised ValueError: malformed node; the literal {3, 5} worked both ways. The hook logs a warning if the upstream code changes.
  • 3MF's "please rate us" popup (after five exports) is switched off.

Phone-home audit (2026-09-28)

  • Manifests: none of the eight declares the network permission (five declare files only).
  • Source: no add-on imports socket, urllib.request, requests, http, subprocess or webbrowser (3MF uses urllib.parse for path joining only). wm.url_open appears only behind buttons a person clicks (CAD Sketcher's help links, 3MF's rating dialog), and safe mode blocks wm.url_open in agent code anyway. The GUI hook logs bpy.app.online_access.
  • Runtime: the full acceptance probe ran with docker run --network none under a Python audit hook watching socket.*, urllib.Request, http.client.*, subprocess.Popen, os.system/exec/posix_spawn and webbrowser.open. Zero events, and every operator passed offline. Positive control: one deliberate socket.getaddrinfo in the same setup showed up as an event. Sensitivity floor: the hook sees Python-level calls only. The native wheels (OCCT, SolveSpace) could open a socket without Python seeing it, but nothing needed the network to work, and none of them is a networking library.

Foot-guns (all measured 2026-09-28)

  • blender --addons x,y is not the same as enabling. It leaves the add-on out of preferences.addons, and Bool Tool and LoopTools read their own prefs in register(), so they failed with a KeyError. The hook enables them the way the Preferences button does.
  • 3D-Print Toolbox writes a cache into its own package dir on first import. On the read-only mount that fails, which is why sync pre-warms the staging copy while it is still writable.
  • CAD Sketcher calls getpass.getuser(), which fails for a uid with no passwd entry. blender-run sets USER; the GUI's abc user exists.
  • CAD Sketcher's sketch operators need the GUI. Creating a sketch activates a workspace tool, and in blender -b there is none: 'NoneType' object has no attribute 'widget'. The add-on loads headless and its solver (slvs) imports; sketch authoring is a GUI/MCP job.
  • Object.dimensions is in local axes and ignores rotation, so it cannot show which way an import faces. The probe uses world-space bounds.
  • STEP Importer orientation: a SurfacePsycho STEP (Z-up, mm) comes back the right way up with the importer's default up_axis="Y". up_axis="Z" stands it on its edge (this is the probe's control for its own orientation check).
  • SurfacePsycho's default patch is 2 x 2 m (Blender units), and STEP export scales ×1000 into millimetres by default. Scale the object to part size first.
  • MeasureIt and LoopTools want a 3D View area. In the GUI, override with the window's VIEW_3D area; headless, a screen datablock's VIEW_3D area works (view3d_override() in the probe).

Acceptance

The probe is scripts/blender-probes/extensions_acceptance.py: one real operator run per add-on, written to pass MCP safe mode so the same code runs on both paths. Files land in fv-ml1:/tank/blender/acceptance/extensions/.

Headless, 2026-09-28: 8 of 9 checks PASS, both online and with --network none: print3d clean non-manifold (4 → 0 non-manifold edges), Bool Tool auto difference, LoopTools circle (radius spread 4.14 mm → 0), MeasureIt segment, SurfacePsycho patch → STEP (sp-patch-headless.step, 20 × 20 mm), STEP Importer read-back (20 × 20 × 0 mm world, plus the wrong-axis control), 3MF out and back (20 mm cube kept its size). The one FAIL is CAD Sketcher, for the GUI-only reason above. These runs used the same docker invocation blender-run makes.

Round trip into build123d (draupnir, 2026-09-28): sp-patch-headless.step imported on irv-ml1 with build123d 0.12.0 as one Shell holding one BSPLINE face, bbox ±10.000000 mm in X/Y at z = 0, area 399.999982 mm² against 400. Extruded 2 mm, it gave a valid 799.999964 mm³ solid. So a real NURBS surface reaches the kernel, not triangles, and millimetres hold across the hop. That is one run of a flat bilinear patch; curved and trimmed patches are untested.

MCP (GUI) path, 2026-09-28 1506 (after Prime ran the deploy): the hook log read enabled 8/8, hardened: surfacepsycho ... literal_eval, online access: False. Through scripts/blender-mcp → execute_blender_code under safe mode, the probe passed 9 of 9, including CAD Sketcher (a sketch on the XY origin plane, then a full solve), because the GUI has the workspace tool that -b lacks. The eval patch held under MCP: the code payload was refused (ValueError: malformed node) and the literal {3, 5} was accepted.

  • CAD Sketcher sketch, re-solved headless: the MCP-made sketch was saved (cad-sketch-mcp.blend), then blender-run --cpu --extensions opened it and view3d.slvs_solve(all=True) returned FINISHED. Authoring is in the GUI; re-solving works in batch.
  • Not solved yet: drawing CAD Sketcher geometry from code. slvs_add_rectangle with p1_fallback/p2_fallback and wait_for_input=False raised 'NoneType' object has no attribute 'co'. Its drawing ops are stateful and expect point picks, and its Python entity API is out of reach under safe mode. Sketches work; agent-drawn profiles need more work.
  • Not confirmed: MeasureIt overlays in get_viewport_screenshot. The segment was created and measureit.runopengl returned FINISHED, but two screenshots (a top view with a 20 mm edge dimension) showed no dimension.
  • blender-run --extensions (the real wrapper, --cpu) ran the same probe at 8 of 9, the one miss being CAD Sketcher for the headless reason above.

Safe-mode note for probe authors: safe mode refuses calling a function held in a variable and any dunder attribute (type(ex).__name__). The probe calls each test by name and uses repr(ex).