Skip to content
BlenderMCP

Dispatch tools

24 first-class MCP tools, all blender_-prefixed, that wrap the addon’s @command handlers. The LLM calls them like any MCP tool; the server registers a Future via JobWaiter, routes a command_dispatch payload through the bus to a Blender peer, awaits the addon’s blender_job_update reply, and returns the result synchronously. The bus round-trip is invisible from the caller’s perspective.

Choose dispatch tools over blender_send_message + script-dispatch unless you need arbitrary Python. See Command dispatch vs script dispatch for the decision tree.

Every dispatch tool accepts the same two infrastructure parameters in addition to its own:

| Parameter | Default | Purpose | |---|---|---| | target_uuid | None | A Blender client UUID, "latest" (newest registration among live clients) or "pid:<n>". If omitted and exactly one live Blender peer is connected, that one is used. Peers with no heartbeat for BLENDER_MCP_CLIENT_STALE_SECONDS (default 180) don’t count. Otherwise returns ambiguous_target, no_live_client or no_client. | | _timeout | varies | Seconds to wait for the addon’s reply. Underscore-prefixed so it doesn’t leak into the addon-side params dict. |

Tools also receive a ctx: Context injected by FastMCP for auth.

Every dispatch tool returns a JSON string with this shape:

{
"status": "completed" | "failed" | "queued" | "running" | "timeout" | "no_client" | "ambiguous_target" | "unknown_target",
"command": "<command name>",
"target_uuid": "<chosen client>",
"job_id": "j-<12 hex>",
"result": <inner JSON>,
"error": "<message or empty>"
}

| Status | Meaning | |---|---| | completed | Addon handler returned successfully; result holds its output | | failed | Addon handler raised; error holds the message | | queued / running | _timeout elapsed but the job is still waiting or still running in Blender. It keeps going and its result is kept; poll it with job_id (see Long-running jobs) | | timeout | _timeout elapsed without an addon reply and the job store was unavailable, so there is no job_id to follow | | no_client | No Blender peer on the user’s bus could be targeted | | ambiguous_target | Multiple Blender peers, no target_uuid to disambiguate | | no_live_client | Several Blender peers are registered but none has heartbeated recently; candidates lists them | | unknown_target | target_uuid was set but no matching client is registered |


Foundational tools. No gating; always available when at least one Blender peer is connected.

blender_get_scene_info(scene: str | None = None, target_uuid: str | None = None, _timeout: float = 30) -> str

Snapshot of the active scene: name, object count, first ~10 objects, materials count, type counts and the user’s selection. scenes lists every scene in the file with its object count and which one is active. Pass scene to summarise another scene without switching the user to it; an unknown name comes back as an error listing the scenes. blender_list_scene_objects takes the same scene argument for the full object list.

A listed object parented to an armature bone carries parent_bone (its location is then relative to that bone). rigs names up to three armatures with up to 16 of their bones each, plus the armature’s how_to_pose text when it has one, so a request to pose or animate a character finds the bones instead of moving the parts.

blender_get_object_info(name: str, target_uuid: str | None = None, _timeout: float = 30) -> str

Detailed descriptor for a specific object by name: transform, mesh stats, material slots. Returns failed if name doesn’t exist.

A parented object also gets parent; parented to a bone, it gets parent_type: "BONE", parent_bone and a pose_note saying to pose that bone rather than the object. An armature gets bone_count, bones (up to 40 names), bone_rotation_mode and the pose_path to key them, such as pose.bones["<bone>"].rotation_euler. Any object with a string custom property how_to_pose gets that text back (up to 1200 characters); scene authors can use it to say how their rig is meant to be posed, as the B. Clip template does.

blender_browse_data(
collection: str | None = None,
item_name: str | None = None,
page: int = 1,
page_size: int = 50,
detail_level: str = "summary",
target_uuid: str | None = None,
_timeout: float = 30,
) -> str

Paginated bpy.data.* browser. Pass collection="objects", "materials", "meshes", etc. Omit collection to list available collections. Pass item_name to drill into a single item. detail_level is summary or full.

blender_execute_code(code: str, target_uuid: str | None = None, _timeout: float = 60) -> str

Execute arbitrary Python in the Blender main thread. Captures stdout and returns it as result. The script’s globals include bpy, bmesh, mathutils. Use this when no dedicated dispatch tool exists for the operation; for multi-step orchestration in a single round-trip, see blender_send_message with a job_dispatch payload.

blender_get_viewport_screenshot(
filepath: str | None = None,
max_size: int = 0,
format: str = "png",
frame: list[str] | None = None,
crop: bool = False,
crop_margin: float = 0.05,
store: bool = False,
annotations: bool = True,
deselect: bool = False,
target_uuid: str | None = None,
_timeout: float = 30,
) -> str

Save a 3D viewport screenshot on the Blender host. deselect=True captures with nothing selected or active, so selection outlines don’t tint the objects, then puts the selection and active object back exactly as they were; the chat’s look_at_viewport always asks for it. Add-ons older than this parameter ignore it. filepath is a path on the Blender machine; leave it out and the image goes to a unique file under Blender’s temp dir (blender_mcp_screenshots/, cleared when Blender exits). Either way the result’s filepath says where it was written. max_size caps the longest edge in pixels; 0 (the default) keeps the viewport’s native resolution. format is png or jpg.

blender_get_console_output(
level: str = "all",
page: int = 1,
page_size: int = 50,
target_uuid: str | None = None,
_timeout: float = 30,
) -> str

Paginated scrape of the Blender console. level filters lines by kind: all, info, warning, error, output. Also exposed as a resource — see blender://console/{level}.

blender_console_operations(
operation: str = "get_info",
params: dict | None = None,
target_uuid: str | None = None,
_timeout: float = 30,
) -> str

Invoke a bpy.ops.console.* operator (e.g. execute, autocomplete, clear). See the addon’s console_operations handler for the operation catalog.


Always-on. Call these before a Tier-3 tool to check whether the corresponding integration is enabled in the addon’s preferences.

blender_get_polyhaven_status(target_uuid: str | None = None, _timeout: float = 10) -> str

Returns whether prefs.use_polyhaven is enabled.

blender_get_hyper3d_status(target_uuid: str | None = None, _timeout: float = 10) -> str

Returns Hyper3D Rodin integration state: enabled flag, mode (MAIN_SITE or FAL_AI), key type (trial vs configured).

blender_get_sketchfab_status(target_uuid: str | None = None, _timeout: float = 30) -> str

Returns Sketchfab integration state. Validates the configured API key against /v3/me (hence the longer timeout).


Bridge to Blender’s RNA bpy.msgbus for change notifications.

blender_msgbus_clear_by_owner(owner_id: str = "default", target_uuid: str | None = None, _timeout: float = 10) -> str

Drop all bpy.msgbus subscriptions owned by owner_id.

blender_msgbus_publish_rna(data_path: str | None = None, target_uuid: str | None = None, _timeout: float = 10) -> str

Publish an RNA-property change. data_path in frame_current, active_object, selected_objects. Pass nothing to flush all pending messages.

blender_msgbus_subscribe_rna(
data_path: str,
owner_id: str = "default",
notify_type: str = "UPDATE",
persistent: bool = True,
target_uuid: str | None = None,
_timeout: float = 10,
) -> str

Subscribe to RNA-property changes. Notifications queue in the addon for later drainage.

blender_msgbus_get_notifications(
owner_id: str | None = None,
clear: bool = False,
target_uuid: str | None = None,
_timeout: float = 10,
) -> str

Drain the addon’s RNA-change notification queue. Set clear=True to also drop the queued entries.

blender_msgbus_list_subscriptions(owner_id: str | None = None, target_uuid: str | None = None, _timeout: float = 10) -> str

List active RNA subscriptions, optionally filtered by owner_id.


Tier 3b: PolyHaven (4, gate: use_polyhaven)

Section titled “Tier 3b: PolyHaven (4, gate: use_polyhaven)”

CC0 asset library. Probe with blender_get_polyhaven_status before calling.

blender_get_polyhaven_categories(asset_type: str, target_uuid: str | None = None, _timeout: float = 30) -> str

asset_type in hdris, textures, models, all.

blender_search_polyhaven_assets(
asset_type: str | None = None,
categories: str | None = None,
target_uuid: str | None = None,
_timeout: float = 30,
) -> str

Search. Addon caps the response at 20 entries.

blender_download_polyhaven_asset(
asset_id: str,
asset_type: str,
resolution: str = "1k",
file_format: str | None = None,
target_uuid: str | None = None,
_timeout: float = 120,
) -> str

Download + import. Models prefer glTF; HDRIs default to .hdr; textures default to .jpg. HDRIs replace the active world, textures create a new material, models append to the active scene.

blender_set_texture(object_name: str, texture_id: str, target_uuid: str | None = None, _timeout: float = 30) -> str

Apply a previously-downloaded texture to object_name. Builds a fresh Principled-BSDF material; handles ARM packing and AO multiplication into base color.


Tier 3c: Hyper3D Rodin (3, gate: use_hyper3d)

Section titled “Tier 3c: Hyper3D Rodin (3, gate: use_hyper3d)”

3D-model generation. Two backends (MAIN_SITE / FAL_AI) selected by addon prefs. Probe with blender_get_hyper3d_status.

blender_create_rodin_job(
text_prompt: str | None = None,
images: list | None = None,
bbox_condition: Any | None = None,
target_uuid: str | None = None,
_timeout: float = 60,
) -> str

Start a text-to-3D or image-to-3D job. images shape varies by backend: MAIN_SITE wants [[suffix, base64_data], ...]; FAL_AI wants [url, ...]. Returns the provider’s raw response (subscription_key or request_id).

blender_poll_rodin_job_status(
subscription_key: str | None = None,
request_id: str | None = None,
target_uuid: str | None = None,
_timeout: float = 10,
) -> str

Pass subscription_key for MAIN_SITE, request_id for FAL_AI.

blender_import_generated_asset(
name: str,
task_uuid: str | None = None,
request_id: str | None = None,
target_uuid: str | None = None,
_timeout: float = 120,
) -> str

Import a completed Rodin GLB. Pass task_uuid for MAIN_SITE, request_id for FAL_AI. Returns the imported object’s name, transform, and bounding box.


Tier 3d: Sketchfab (2, gate: use_sketchfab)

Section titled “Tier 3d: Sketchfab (2, gate: use_sketchfab)”

Marketplace asset import. Probe with blender_get_sketchfab_status.

blender_search_sketchfab_models(
query: str,
categories: str | None = None,
count: int = 20,
downloadable: bool = True,
target_uuid: str | None = None,
_timeout: float = 30,
) -> str

Returns the raw Sketchfab v3 search response.

blender_download_sketchfab_model(uid: str, target_uuid: str | None = None, _timeout: float = 120) -> str

Download + import by UID. Zip-slip protected.


Read what the user drew with Blender’s annotation tool (D-key and drag), and draw marks of your own to point things out. Blender 5.x keeps these strokes in bpy.data.annotations; the scene’s annotation datablock holds one or more layers.

Write tools draw on their own layer, LLM by default, created orange and slightly thicker than the user’s default so the two are easy to tell apart. The user’s layers are never changed unless you name one. Strokes you draw read back through blender_list_annotations and blender_get_annotation like anything the user drew.

blender_list_annotations(include_bbox: bool = True, limit: int = 200, target_uuid: str | None = None, _timeout: float = 30) -> str

Every stroke with its id, layer, point count, colour, coordinate space and, for 3D strokes, a world-space bounding box. Cheap: no point coordinates.

blender_get_annotation(annotation_id: str, target_uuid: str | None = None, _timeout: float = 30) -> str

Full geometry for one stroke. world_points is filled only for space: "3D"; view-space strokes return raw_points in viewport-percentage units and report their space as 2D-Local.

blender_add_annotation_stroke(points: list[list[float]], layer: str = "LLM", color: list[float] | None = None, thickness: int | None = None, space: str = "3D", cyclic: bool = False, target_uuid: str | None = None, _timeout: float = 30) -> str

Draw one stroke. With space="3D", points are world coordinates [x, y, z] and the mark sits in the scene. With space="view", points are [x, y] percentages of the viewport region (0 to 100, origin bottom-left) and the mark stays fixed on screen. color ([r, g, b], 0 to 1) and thickness (1 to 10) apply to the whole layer, because Blender stores them per layer; use another layer name for another colour. cyclic closes the stroke. Returns the stroke id.

Arguments are checked before anything is sent to Blender: fewer than two points, wrong point shapes, colours outside 0 to 1 or thickness outside 1 to 10 return invalid_argument immediately.

blender_annotate_object(object: str, style: str = "box", label: str | None = None, layer: str = "LLM", color: list[float] | None = None, thickness: int | None = None, padding: float = 0.05, target_uuid: str | None = None, _timeout: float = 30) -> str

Mark an object so the user can see which one you mean. box outlines its world-space bounding box (six strokes), circle rings it at mid height, arrow comes down at an angle and stops just above its top. padding grows the outline by that fraction of the object’s largest side. Annotations can’t hold text, so label is echoed back with label_drawn: false; put it in your reply instead.

blender_clear_annotations(layer: str = "LLM", target_uuid: str | None = None, _timeout: float = 30) -> str

Remove every stroke on one layer and report how many. Other layers are untouched and the layer itself is kept. A missing layer returns found: false, not an error.

Marks are drawn on the layer’s earliest frame so they stay visible while the timeline moves, and the annotation overlay is switched on in every 3D viewport.

Assembly analysis and keyframes (5, ungated)

Section titled “Assembly analysis and keyframes (5, ungated)”

For physical assemblies and keyframed configurations, where bounding boxes overlap far more often than parts actually intersect, and where Blender 5.x removed action.fcurves. Every tool evaluates the scene as shown (modifiers, animation), and any frame argument puts the scene back on its original frame afterwards.

blender_check_interference(
objects: list[str] | str | None = None, # None = visible geometry, "all" includes hidden
frame: int | None = None,
frames: list[int] | None = None, # sweep up to 50 frames
ignore_pairs: list[list[str]] | None = None,
include_hidden: bool = False,
tolerance: float = 0.0,
check_containment: bool = True,
sample_points: int = 3,
target_uuid: str | None = None,
_timeout: float = 60,
) -> str

Tests every pair whose world bounds overlap for real triangle intersection on evaluated, world-space meshes. Returns intersecting pairs with a triangle_pairs count and sample points, contained pairs (a part entirely inside another crosses no triangles, so it’s found by an inside test that needs closed meshes with outward normals), and bbox_overlap_pairs so you can see how many box overlaps were false alarms. Faces that only touch coplanar don’t count as intersecting; raise tolerance (scene units) to catch near or touching contact. Use ignore_pairs for intended contact such as snap fits. Up to 300 objects per call; a long sweep comes back as a job to follow with blender_job_status.

blender_world_bounds(
objects: list[str] | str | None = None,
frame: int | None = None,
include_hidden: bool = False,
exact: bool = False,
target_uuid: str | None = None,
_timeout: float = 30,
) -> str

World-space min, max, size and center per object, plus their union. The default transforms the evaluated local bound box, which never under-reports but can over-report for rotated parts; exact=True uses evaluated vertices for tight bounds.

blender_get_keyframes(object: str, data_path: str | None = None, owner: str = "object",
target_uuid: str | None = None, _timeout: float = 30) -> str

Channels of the object’s assigned action with each key’s frame, value and interpolation. Reads Blender 5.x slotted Actions through the assigned slot’s channelbag and falls back to legacy actions; api in the result says which. owner is "object", "data" (a light’s energy, a camera’s lens) or "shape_keys".

blender_insert_keyframe(
object: str,
data_path: str,
frame: float,
value: float | list[float] | bool | str | None = None,
index: int = -1,
overwrite: bool = False,
interpolation: str | None = None,
owner: str = "object",
target_uuid: str | None = None,
_timeout: float = 30,
) -> str

Sets the frame, then the value, then inserts the key. That order matters: setting a value and then changing frame re-evaluates the animation and silently discards the value. An existing key on the same channel and frame is never replaced unless overwrite=True, and replaced values come back in replaced, so an authored pose can’t be destroyed by accident. Omit value to key the property’s current value.

blender_set_interpolation(object: str, interpolation: str, data_path: str | None = None,
frames: list[float] | None = None, owner: str = "object",
target_uuid: str | None = None, _timeout: float = 30) -> str

Sets CONSTANT, LINEAR, BEZIER or any other Blender interpolation on the object’s keys, optionally for one property and/or specific frames.


Booleans that check their inputs and report what they did. Blender’s own boolean modifier fails in ways that look like success, so these tools measure the target before and after every operand.

blender_mesh_health(objects: list[str] | str, evaluated: bool = False,
target_uuid: str | None = None, _timeout: float = 30) -> str

Per object: vertex, edge and face counts; non-manifold edges broken down into boundary_edges (open), multi_face_edges (shared by three or more faces), wire_edges and non_contiguous_edges (flipped normals); loose_vertices, zero_area_faces, connected shells, whether the mesh is closed, its volume in world units, and world bounds. volume is null when the mesh isn’t closed, because an open mesh has no meaningful volume. evaluated=True measures the mesh with its modifiers; the default reads the stored mesh. Nothing is modified.

blender_boolean(
target: str,
operands: list[str] | str,
operation: str = "DIFFERENCE", # DIFFERENCE, UNION, INTERSECT
solver: str = "EXACT", # EXACT, FLOAT, MANIFOLD (no FAST in 5.x)
apply: bool = True,
check: bool = True,
force: bool = False,
use_self: bool = False,
use_hole_tolerant: bool = False,
coplanar_tolerance: float = 1e-4,
coplanar_nudge: float | None = None,
keep_operands: bool = True,
hide_operands: bool = True,
target_uuid: str | None = None,
_timeout: float = 60,
) -> str

Operands are applied one at a time. Each entry in steps gives the face count, volume and non-manifold edge count before and after, plus that step’s warnings, so a bad result can be traced to the operand that caused it.

With check on (the default), the call is refused and nothing changes when:

  • the target or an operand isn’t a closed manifold solid (open, wire or three-face edges, flipped normals), or
  • an operand’s faces lie in the same plane as the target’s, either coincident (same direction) or abutting (touching back to back).

The refusal names the problem and where it was found. Fix the inputs, pass coplanar_nudge (a small distance in scene units: a temporary copy of each operand is pushed outward by that much so no faces share a plane), or pass force=True to run anyway.

Warnings flag results that look fine but aren’t:

  • a DIFFERENCE or INTERSECT that changed nothing,
  • a UNION that left the target unchanged (the operand was dropped, unless it lies entirely inside),
  • a UNION smaller than one of its inputs, or a DIFFERENCE that increased the volume,
  • new non-manifold edges, or a result that is no longer a closed solid.

use_self also unions overlapping parts within the target, which cleans up duplicated or overlapping pieces before trimming. apply=False leaves the boolean modifiers on the target for inspection. Operands are kept and hidden by default; keep_operands=False deletes them after applying. Modifiers are applied to the base mesh, ahead of any modifiers the target already has.

These came from a real modelling session (a house roof built from nine unioned gables) and are what the checks above look for:

  • Faces sharing a plane corrupt the result. Unioning solids that abut on the same plane, or cutting with an operand whose face lies exactly on the target’s surface, can leave non-manifold edges with no error. The mesh still renders correctly, so nothing looks wrong until a later boolean on it misbehaves. Overlap abutting parts slightly (a few hundredths of a unit) instead of letting faces meet exactly, or use coplanar_nudge.
  • A boolean can do nothing, or drop an operand, and report success. Face counts alone don’t catch this, because valid unions also remove faces. The volume check does: a union can never be smaller than either input, and a difference can never be larger than its target.
  • Volume stops meaning anything once a mesh is non-manifold, so non-manifold edges are reported on every step rather than relying on volume alone.
  • Solvers fail differently. In testing, FLOAT produced non-manifold results on coplanar and abutting inputs where EXACT and MANIFOLD stayed clean. MANIFOLD needs clean closed inputs. Blender 5.x has no FAST; FLOAT replaced it.
  • Sometimes a boolean is the wrong tool. A flat plate with a hole is better built by clipping the outline and extruding it than by cutting a solid.

The addon reports its job queue about every 10 seconds from its network thread, so a report still arrives while Blender’s main thread is busy. blender_list_available_clients shows it as health (queue_depth, active_jobs with running_seconds, drain_timer_registered, last_drain_seconds_ago, reported_seconds_ago), and a dispatch that times out or comes back queued includes it as target_health, with a hint that tells the three usual causes apart: Blender busy with a long job, a stalled job pump, or a dispatch that hasn’t arrived yet.

blender_reset_pump(target_uuid: str | None = None, bus_id: str | None = None) -> str

Restarts the addon’s job pump over the bus, for the “connected, jobs queued, nothing running” stall that used to need a click on Disconnect and Connect in the sidebar. The request travels with the addon’s next check-in (up to about 10 seconds) and runs on its network thread, so it works even when no dispatched command can. It can’t help when Blender’s main thread is busy with a long job; health in the reply shows which case you’re in.


The recommended pattern: probe with a Tier-2 status tool first, branch on the result. Example:

status = json.loads(await client.call_tool("blender_get_polyhaven_status", {}))
if json.loads(status["result"]).get("enabled"):
assets = await client.call_tool(
"blender_search_polyhaven_assets", {"asset_type": "textures"}
)
else:
raise RuntimeError("PolyHaven disabled — enable in addon prefs")
  • Bus tools — the control plane (blender_send_message, blender_register_client, etc.)
  • Addon commands — the underlying @command handler registry these tools wrap
  • Command dispatch vs script dispatch — when to use these tools vs blender_send_message with a script
  • Resources — blender://scene/* and blender://console/* share the same dispatch round-trip