Architecture
BlenderMCP is three pieces: any number of LLM clients on one side, any number of Blender addon clients on the other, and a single MCP server in the middle holding a per-user message bus.
The diagram
Section titled “The diagram” ┌────────────────────────┐LLM client A ─────────┤ │LLM client B ─────────┤ MCP Server (HTTP) ├─── Blender addon XLLM client C ─────────┤ JWT middleware + ├─── Blender addon Y │ per-user buses ├─── Blender addon Z └────────────────────────┘Every connection is a standard MCP transport. The server doesn’t speak any custom protocol. Every “send a job” / “reply to a job” is an MCP tool call or an MCP log notification.
Per-tier breakdown
Section titled “Per-tier breakdown”LLM clients
Section titled “LLM clients”Any process that speaks MCP can be a client. The reference client (examples/llm_client_example.py) uses fastmcp.Client over StreamableHttpTransport. Login at POST /auth/login returns a JWT, which is set as the Authorization bearer header on the transport.
LLM clients are typically ephemeral — is_persistent=False. They join the bus, dispatch some jobs, await replies, leave.
MCP server
Section titled “MCP server”A FastAPI app that mounts:
POST /auth/loginandPOST /auth/refresh— the OAuth surface/mcp— the Streamable HTTP MCP endpoint produced bybuild_http_mcp()
A request-scoped middleware:
- Reads the bearer token from the
Authorizationheader. - Verifies the JWT.
- Sets
current_user_id(aContextVar) before dispatch. - Tool calls read
current_user_id.get()to resolve whichUserMessageBusto operate on.
The server holds:
bus_manager: BusManager—dict[user_id, UserMessageBus]_pending_jobs: dict[job_id, tuple[user_id, originator_uuid]]— for routing repliesjob_waiter: JobWaiter—dict[(user_id, job_id), asyncio.Future]for dispatch-tool round-trips- A
BusForwardingHandlerattached to the_message_buslogger — every routed message is onelog.info(..., extra={...})call, the handler turns it into MCPnotifications/messageand pushes via the rightServerSession.
The MCP tool surface registered by build_http_mcp():
BlenderDiagnosticsComponent— Blender install probes (5 tools)BlenderBusComponent—register_client,send_message,job_update, etc. (5 tools, 2 resources, 1 prompt)BlenderDispatchComponent— flat round-trip tools for the 24 addon commands (24 tools, 4 resources)BlenderPromptsComponent— skeletal scripting + workflow prompts (8 prompts)
Blender addons
Section titled “Blender addons”Each addon installation is one FastMCP Client running on a worker thread inside Blender. The package layout:
Directoryaddon/
- init .py register() / unregister()
- _version.py
- constants.py
- identity.py sticky UUID generation
- preferences.py AddonPreferences (Phase 8)
- state.py _client, _executor singletons
Directoryauth/
- login.py pure-requests /auth/login + /auth/refresh
Directoryclient/
- bus_client.py FastMCP lifecycle + asyncio loop
- message_pump.py _on_message notification filter
- drainer.py main-thread timer, priority-queue drain (job_dispatch + command_dispatch)
- job_reporter.py blender_job_update reply
Directoryexecutor/
- init .py BlenderCommandExecutor (MRO-mixed)
- registry.py @command + CommandSpec
- _shared.py SharedHelpersMixin
Directoryhandlers/ 8 per-domain mixins
- …
Directoryui/
- panel.py View3D sidebar panel
- operators.py Login, Start/Stop operators
The addon is persistent (is_persistent=True). It registers with a sticky UUID from addon/identity.py so reconnects keep the same bus identity.
Two dispatch paths
Section titled “Two dispatch paths”Both end at the same place — a Blender main-thread execution that reports back via blender_job_update. They diverge in what the LLM sends and how the reply reaches it.
Path A — dispatch tool (command_dispatch)
Section titled “Path A — dispatch tool (command_dispatch)”The recommended LLM UX. The MCP client calls a flat tool; the server hides the bus.
-
LLM client calls
blender_get_object_info(name="Cube")— a normal MCP tool invocation. -
Middleware verifies the JWT, sets
current_user_id = "demo". -
BlenderDispatchComponent._dispatchpicks a target Blender client (errors withno_client/ambiguous_targetif zero/many), generatesjob_id = "j-7a3f2c1e9b04", callsjob_waiter.register(("demo", "j-..."))which returns anasyncio.Future. -
The dispatch helper routes a
command_dispatchpayload ({message_type, job_id, command:"get_object_info", params:{name:"Cube"}}) on the user’s bus, addresseddirectto the chosen target._pending_jobs[job_id] = ("demo", "server-dispatch:demo")is recorded sojob_updatecan find the originator later. -
The bus emits one
_message_buslog record; theBusForwardingHandlerpushes an MCPnotifications/messagedown the target’sServerSession. -
Addon’s
message_pumpdecodes, pushes onto the priority queue. -
Addon’s
drainerpops the entry, matchespayload.message_type == "command_dispatch", callsexecute_command(job_id, "get_object_info", {"name":"Cube"}). The executor’s@command-registered handler runs on Blender’s main thread, returns a dict. -
job_reporter.submit_job_updatecallsblender_job_update("j-...", "completed", result=<json>, error="")over the addon’s MCP transport. -
Server-side
job_updateroutes the reply on the bus and callsjob_waiter.deliver("demo", "j-...", "completed", result, error). -
The Future resolves. The dispatch tool’s
await asyncio.wait_for(future, timeout=30)returns, the tool serializes its wire body, the MCP client gets back JSON.
The LLM sees one tool call → one JSON result. The bus round-trip is invisible.
Path B — script dispatch (job_dispatch)
Section titled “Path B — script dispatch (job_dispatch)”For custom orchestration: arbitrary scripts, multi-step flows, or LLM clients that want to observe the bus directly.
-
LLM client calls
blender_send_messagewithtarget_uuid="blender-abc",from_uuid="llm-mine",priority="info",payload={message_type:"job_dispatch", job_id:"job-1", script:"..."}. -
Middleware verifies the JWT, sets
current_user_id = "demo". -
BlenderBusComponent.send_messagerecords_pending_jobs["job-1"] = ("demo", "llm-mine"), callsbus.route(...). -
Bus + forwarding handler deliver an MCP
notifications/messageto the addon’s session (steps 4–6 of Path A, identical). -
Addon’s drainer matches
payload.message_type == "job_dispatch", callsexecute_script(job_id, script). The scriptexecs in the main thread withbpy,bmesh,mathutils,executorin scope; stdout is captured. -
job_reporter.submit_job_updatecallsblender_job_update("job-1", "completed", result=<stdout>, error=""). -
Server-side
job_updatelooks up_pending_jobs["job-1"], routes the replydirecttollm-mine. The same call also firesjob_waiter.deliver(...)— a no-op here becausellm-minenever registered a server-side Future. -
LLM client’s
on_messagefilters on_message_bus, decodes, seespayload.kind == "job_update", resolves its own client-side JobWaiter forjob-1. The caller’sawaitreturns.
The shared reply machinery
Section titled “The shared reply machinery”Steps 6–9 in Path A and steps 5–7 in Path B are the same code. blender_job_update doesn’t know which path the dispatch came from. The two paths diverge only in:
- What the addon’s drainer dispatches —
execute_scriptvsexecute_command(oneif/elifbranch indrainer.py). - Who holds the reply Future —
JobWaiteron the server for Path A, the LLM client’s own waiter for Path B.
job_waiter.deliver() is intentionally a no-op when no Future is registered for the (user_id, job_id) key. That makes the JobWaiter purely additive: legacy send_message + listen-for-notification clients keep working unchanged, and the dispatch tools just happen to register a Future before sending.
Key properties
Section titled “Key properties”- No state on disk. Bus contents are in-memory. Restarts drop every persistent client and every
_pending_jobsentry. Persistent clients reconnect on their own; pending jobs are orphaned and broadcast their replies as a fallback. - Per-user isolation. User A and user B have separate
UserMessageBusinstances. There’s no path from A’s bus to B’s, including forjob_update. - No special protocol. Everything is standard MCP. A vanilla
fastmcp.Clientwith amessage_handlerand a working JWT can participate as a client.
Related
Section titled “Related”- Command dispatch vs script dispatch — when to use each path
- Dispatch tools reference — all 24 tool signatures
- Why MCP logging as a bus — the design rationale
- OAuth and per-user buses — JWT, ContextVar, isolation
- Addon package tour — why the addon is split this way