Articulate.
Articulate Lab · infrastructure · 16 Aug 2026

Six hops to one.

For two days every instruction to Blender crossed a tunnel, an SSH session, a base64 encode and a raw socket before it arrived. It cost three to eight seconds a call and it collapsed whenever the tunnel dropped. Replacing it took twenty minutes and made every call afterwards 0.25 seconds.

1 · The six hops

The Blender Lab add-on exposes a bridge, but not an MCP one. It holds a raw TCP socket on localhost:9876 and speaks null-delimited JSON. Nothing in the MCP layer knows it exists.

Meanwhile the agent runs in a cloud sandbox, not on the Mac. So every single call — read an object's position, move a wheel, start a render — travelled this:

before six hops, 3–8 s each
sandbox→ tailscale SOCKS5→ paramiko SSH→ base64→ bl_client.py→ socket 9876→ Blender

Paramiko rather than ssh because the sandbox has no ssh binary. Base64 because arbitrary Python does not survive a shell quote. And the whole thing rested on a userspace SOCKS5 proxy that dropped twice in one night — each time taking a full re-bootstrap to recover, mid-task, with work in flight.

The tell. It dropped for the third time while writing up its own replacement. That is a fair summary of the problem.

2 · What replaced it

A stdio MCP server — 380 lines of Python, zero third-party imports, running on the stock macOS /usr/bin/python3. It speaks JSON-RPC on stdin and stdout, and forwards to the socket that was always sitting there on loopback.

after one hop, 0.25 s
agent→ blender_mcp_server.py→ Blender

No tunnel. No SSH. No encoding. Nothing left to drop.

The reason this works is that the desktop app already proxies locally-installed MCP servers out to the agent. Chrome, Figma, PDF tools, Apple Notes — all reachable in one hop. Blender was absent from that list for one reason only: it was never registered as an MCP server. It was a Blender add-on holding a socket. The entire fix is a translator between the two.

3 · The numbers

beforeafter
hops61
per call3–8 s0.25 s
third-party dependenciestailscaled, paramiko, pysocksnone
failure modes in one night3 tunnel drops0
build time~20 minutes

Measured, not estimated — the server logs its own round-trip time to stderr on every call. Roughly a dozen calls followed immediately after installation, including geometry generation, depsgraph measurement, material assignment and two Cycles renders. No failures, no reconnects.

4 · The four tools

blender_exec

Arbitrary Python in the live session. Returns stdout, the result and any traceback. Everything else is a shortcut over this.

blender_state

Open file, unsaved-changes flag, object count, engine and resolution, active camera, every collection with counts and view-layer exclusion, current selection.

blender_bounds

True world-space bounds of objects or collections, evaluated through the depsgraph so modifiers and shape keys count.

blender_save

Save, or save-as to an explicit path.

Failures, encoded as tools

Three of those four exist because of specific mistakes, and that is the point. A lesson written in a document gets forgotten. A lesson built into a tool cannot be.

blender_bounds exists because the data you ask for is not the data you see. Reading object.dimensions or mesh.vertices[].co through this bridge returns the un-evaluated basis mesh, because no depsgraph tick happens between the write and the read. Three generated humans of visibly different heights all measured 1.695 m — identical to the millimetre. The same class of staleness had already put a jockey wheel 1.36 m out of position and hung an awning at double height. Now the correct measurement is one call, so it is easier to do it right than wrong.

blender_state exists because the open file is often not the one you assume. Sessions switch files underneath you. One call, and you cannot edit the wrong .blend.

blender_save takes an explicit path because a recovery session once left Blender pointed at a .blend1 backup, and two hours of work landed in the backup file with no error shown anywhere.

The blender_exec description itself carries two warnings, so any future session reads them before its first call rather than rediscovering them:

# 1. matrix_world and object.dimensions are STALE after you change a
#    transform. Use blender_bounds, which evaluates the depsgraph.

# 2. NEVER call bpy.ops.wm.read_homefile() — it kills the add-on and
#    quits Blender outright. Delete objects explicitly and save_as_mainfile.

5 · Installing it

One entry in ~/Library/Application Support/Claude/claude_desktop_config.json:

"blender": {
  "command": "/usr/bin/python3",
  "args": ["~/ClaudeWork/Claude/mcp-blender/blender_mcp_server.py"]
}

Quit the desktop app properly and reopen — it only reads MCP config at launch. Blender must be running with the Lab add-on listening on 9876.

Two details that matter more than they look. Diagnostics go to stderr, never stdout, because stdout is the protocol stream and one stray print corrupts it. And when Blender is not running, the error returned is a sentence explaining exactly that — not a paramiko stack trace about a SOCKS proxy. The first time the new bridge failed, it said "Could not reach Blender on 127.0.0.1:9876. Blender must be running with the Lab MCP add-on enabled and listening." That was the whole diagnosis, in one line.

6 · The general lesson

The tunnel was never the problem. The tunnel was the workaround, and it worked well enough to stop anyone asking why it was needed. Two days of tax got paid on every call because the thing was functioning.

A workaround that works is the most expensive kind. It never fails loudly enough to get fixed, and it never gets cheap enough to be free.

The right question was not "how do I make the tunnel more reliable". It was "why is there a tunnel at all?" — and the answer was a missing thirty-line translator between two things that were already on the same machine.

Worth asking of any pipeline that has quietly grown a middle: what is actually being bridged, and does the gap still exist?


Built 16 Aug 2026, ~02:00 GST, at the end of Blender Day 2. Source and README live in ~/ClaudeWork/Claude/mcp-blender/.