Skip to content

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.

┌────────────────────────┐
LLM client A ─────────┤ │
LLM client B ─────────┤ MCP Server (HTTP) ├─── Blender addon X
LLM 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.

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 ephemeralis_persistent=False. They join the bus, dispatch some jobs, await replies, leave.

A FastAPI app that mounts:

  • POST /auth/login and POST /auth/refresh — the OAuth surface
  • /mcp — the Streamable HTTP MCP endpoint produced by build_http_mcp()

A request-scoped middleware:

  1. Reads the bearer token from the Authorization header.
  2. Verifies the JWT.
  3. Sets current_user_id (a ContextVar) before dispatch.
  4. Tool calls read current_user_id.get() to resolve which UserMessageBus to operate on.

The server holds:

  • bus_manager: BusManagerdict[user_id, UserMessageBus]
  • _pending_jobs: dict[job_id, tuple[user_id, originator_uuid]] — for routing replies
  • job_waiter: JobWaiterdict[(user_id, job_id), asyncio.Future] for dispatch-tool round-trips
  • A BusForwardingHandler attached to the _message_bus logger — every routed message is one log.info(..., extra={...}) call, the handler turns it into MCP notifications/message and pushes via the right ServerSession.

The MCP tool surface registered by build_http_mcp():

  • BlenderDiagnosticsComponent — Blender install probes (5 tools)
  • BlenderBusComponentregister_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)

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.

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.

  1. LLM client calls blender_get_object_info(name="Cube") — a normal MCP tool invocation.

  2. Middleware verifies the JWT, sets current_user_id = "demo".

  3. BlenderDispatchComponent._dispatch picks a target Blender client (errors with no_client / ambiguous_target if zero/many), generates job_id = "j-7a3f2c1e9b04", calls job_waiter.register(("demo", "j-...")) which returns an asyncio.Future.

  4. The dispatch helper routes a command_dispatch payload ({message_type, job_id, command:"get_object_info", params:{name:"Cube"}}) on the user’s bus, addressed direct to the chosen target. _pending_jobs[job_id] = ("demo", "server-dispatch:demo") is recorded so job_update can find the originator later.

  5. The bus emits one _message_bus log record; the BusForwardingHandler pushes an MCP notifications/message down the target’s ServerSession.

  6. Addon’s message_pump decodes, pushes onto the priority queue.

  7. Addon’s drainer pops the entry, matches payload.message_type == "command_dispatch", calls execute_command(job_id, "get_object_info", {"name":"Cube"}). The executor’s @command-registered handler runs on Blender’s main thread, returns a dict.

  8. job_reporter.submit_job_update calls blender_job_update("j-...", "completed", result=<json>, error="") over the addon’s MCP transport.

  9. Server-side job_update routes the reply on the bus and calls job_waiter.deliver("demo", "j-...", "completed", result, error).

  10. 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.

For custom orchestration: arbitrary scripts, multi-step flows, or LLM clients that want to observe the bus directly.

  1. LLM client calls blender_send_message with target_uuid="blender-abc", from_uuid="llm-mine", priority="info", payload={message_type:"job_dispatch", job_id:"job-1", script:"..."}.

  2. Middleware verifies the JWT, sets current_user_id = "demo".

  3. BlenderBusComponent.send_message records _pending_jobs["job-1"] = ("demo", "llm-mine"), calls bus.route(...).

  4. Bus + forwarding handler deliver an MCP notifications/message to the addon’s session (steps 4–6 of Path A, identical).

  5. Addon’s drainer matches payload.message_type == "job_dispatch", calls execute_script(job_id, script). The script execs in the main thread with bpy, bmesh, mathutils, executor in scope; stdout is captured.

  6. job_reporter.submit_job_update calls blender_job_update("job-1", "completed", result=<stdout>, error="").

  7. Server-side job_update looks up _pending_jobs["job-1"], routes the reply direct to llm-mine. The same call also fires job_waiter.deliver(...) — a no-op here because llm-mine never registered a server-side Future.

  8. LLM client’s on_message filters on _message_bus, decodes, sees payload.kind == "job_update", resolves its own client-side JobWaiter for job-1. The caller’s await returns.

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 dispatchesexecute_script vs execute_command (one if/elif branch in drainer.py).
  • Who holds the reply FutureJobWaiter on 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.

  • No state on disk. Bus contents are in-memory. Restarts drop every persistent client and every _pending_jobs entry. 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 UserMessageBus instances. There’s no path from A’s bus to B’s, including for job_update.
  • No special protocol. Everything is standard MCP. A vanilla fastmcp.Client with a message_handler and a working JWT can participate as a client.