MifBlender add-on reference
The MifBlender add-on beyond the install steps: the sidebar and viewport overlay, the checks ping reports, edge selection, tiling-safe bevels and skirts, and the FBX axis and scale rules for Unreal. For anyone who uses the add-on's operations directly or wants to know what it does with their scene.
MifBlender covers the protocol, ports, security and undo, and Free Blender MCP covers installing it. This page covers the rest. Through the MCP server the operations below are the bl_<op> tools, so bevel_edges is bl_bevel_edges. Speaking to the socket directly, send the bare operation name.
The sidebar and the viewport overlay
The N-panel ▸ MifBridge tab answers two questions before anything else: is this Blender set up right, and what has the AI been doing here.
- At the top. Whether the server is listening and on which port, this Blender's process id, how many clients are connected and when the last call came. The calls, refusals and writes so far, and the slowest call. Which clients made them (
claude-code,codex-mcp-clientand so on). And the health checks: the token,run_python, whether the install matches its release and is the only MifBlender Blender can see, whether the code on disk changed since Blender started, whether another Blender MCP add-on is enabled or answering on port 9876, and where the call journal is written. A check that needs attention carries the button that fixes it, and hovering any check shows the detail. - Below. Every call, grouped by what it works on (Mesh, Materials, Render and so on) with its time. A refused call is red with the reason. You can filter to writes or refusals, or search. Click a call for everything about it: what was sent, who sent it, when, and the whole refusal with a button to copy it.
- Folded away. The session (file, unsaved changes, bridge writes, undo steps, scene, render engine and frames), the add-on (version, operation count by family, the code's hash, its folder) and the settings.
The viewport overlay draws the last calls over the 3D view. Turn it on with the button under the calls or the Overlays popover. It is off by default, hides with the viewport's overlays (Alt+Shift+Z), and collapses to a one-line strip after 10 seconds without a call, opening again when the next one arrives. The corner, number of calls, opacity and idle time are in the add-on preferences. It costs about 0.02 ms a frame when nothing changed (measured on Blender 5.2.1).
If an agent's work does not show up in this panel, it did not go through MifBlender. It reached Blender some other way, typically through another Blender MCP add-on.
Another Blender MCP add-on
Another add-on of that kind listens on port 9876, so it and MifBlender can run at the same time. They do not share a connection: an agent talking to the other one is not using MifBlender, and its edits do not appear in MifBlender's call list. The sidebar warns when the other add-on is enabled or answering.
What ping tells you
ping is the cheap liveness probe, and the one to call first. It returns the version, the process id, whether Blender is running in the background, the operation list, and whether run_python is allowed. For generative operations it reports allowGenerative, generativeMode and generativeAllowed.
The process id says which Blender owns the port. Compare it with the N-panel, and if they disagree, your edits are landing in a different Blender.
Which copy of the add-on is running
Blender loads the add-on from its own scripts/addons folder, and keeps whatever it imported at startup for the life of the process. ping says which copy answered:
| Field | Means |
|---|---|
addonPath |
The folder the running package was loaded from. Compare it with the folder you think you are running |
sourceHash |
What is on disk at that path right now |
sourceHashAtImport |
What was there when this Blender started |
sourceChangedSinceStart |
The two hashes differ. It is null if the sources cannot be read |
install |
ok, duplicates (other MifBlender* folders beside this one, each loaded as another add-on) and, against the release zip's MANIFEST.json, missing, changed and unexpected. note says what to do. A source checkout has no manifest, and manifestPresent says so |
If addonPath is not the folder you expected, you are editing one copy and running another. If sourceChangedSinceStart is true, it is the right copy edited since Blender started: restart Blender or re-enable the add-on, because a running Blender keeps the modules it imported. Both look like "my new operation is not there".
Upgrading cleanly
Do not install a new zip over an enabled MifBlender. Installing over it can leave a new __init__.py beside old operation files and a missing new one, while the Add-ons list shows one ticked entry. A backup folder kept inside scripts/addons, under any name, is loaded by Blender as another add-on.
- Close Blender.
- In
%APPDATA%\Blender Foundation\Blender\<version>\scripts\addons\, move theMifBlenderfolder and every otherMifBlender*folder somewhere outsidescripts\addons. - Extract the new zip there, so
scripts\addons\MifBlender\__init__.pyexists. - Start Blender and read
ping'sinstallblock.okis true when no other MifBlender folder sits beside it and every file matches the release's manifest.
Reading the scene
| Operation | What it gives you |
|---|---|
scene_info |
A census of the scene plus unitSettings. It warns when scaleLength is not 1.0 (see FBX scale below). Takes detail, limit and nameExcludes |
list_objects |
Names and types, and for meshes the vertex and triangle counts, dimensions and material slots. Filter by type or pattern |
object_info |
One object: local bounds in both Blender units and Unreal units, transform, counts, material slots in order, UV layers and whether it has custom normals |
object_info is the "before" picture to compare against after a round trip through Unreal.
Importing and exporting meshes
import_mesh reads FBX and glTF (.fbx, .gltf, .glb). Every other format is import_scene's. It finds the new objects by comparing the scene before and after, because Blender's import operators do not return them, and it warns when an imported object has a non-identity transform. useCustomNormals is an FBX importer option and is refused on glTF. So is importAnimation: false, because Blender's glTF importer always brings animation in.
export_mesh writes FBX only. glTF, OBJ, USD, Alembic, STL and PLY are export_scene's. It takes object or objects, the FBX exporter's options (each also under the exporter's own snake_case name) and overwrite. After writing, it checks the file exists and is not empty, and fails if either is untrue. It restores your selection.
Both reject any format that is not theirs with a message, instead of guessing. OBJ in particular is not round-trip safe against Unreal: Unreal's OBJ exporter swaps Y and Z, de-indexes to three vertices per triangle and writes no normals. Use run_python for another format and accept that the orientation is then yours to get right.
run_python
The escape hatch. It runs on the main thread like every other operation and returns stdout, stderr and whatever the code assigns to result, which starts each call as None. Names a call defines are still there for the next, as in a console, until a .blend loads. bpy, bmesh, math, mathutils and its Vector, Matrix, Euler, Quaternion and Color need no import. The Allow run_python preference gates it. See MifBlender.
Selecting edges
select_edges, bevel_edges and extrude_skirt share one selector, with flat keys. Every criterion you supply is combined with AND, and it refuses to run with none:
| Key | Selects |
|---|---|
minAngleDeg and maxAngleDeg |
Edges by the angle between the two faces they join. 0 is coplanar and 90 is a box corner. "Chamfer everything sharper than X" is this |
axis (X, Y or Z), side (min, max or both) and tolerance |
Edges lying in the object's minimum or maximum plane along an axis |
boundaryOnly |
Edges with exactly one linked face |
edgeIndices |
An explicit list |
allEdges |
Every edge. Say it out loud if you mean it |
There is no nested selector object on the socket. The MCP server's bl_* tools accept a nested selector because it reads better at a call site, and flatten it before sending. If you speak to the socket directly, send the flat keys.
select_edges is the tool for iterating on a selector. It writes nothing and runs the same selection the two editing operations run, so what it reports is what they would act on: count, boundaryEdges, interiorEdges, wireEdges and edgeIndices. A non-zero interiorEdges is exactly why extrude_skirt would refuse.
bevel_edges
offset (Blender units) or offsetUU (Unreal units), exactly one. Also segments (1 gives a chamfer, more gives a round), profile, offsetType, clampOverlap, loopSlide, hardenNormals, miterOuter, miterInner and spread. dryRun: true reports the matched edge count and indices and changes nothing.
The tiling guard
A bevel moves the endpoints of every edge it touches. Bevelling the long sides of a tiling mesh also drags its end-cap vertices inward, and the tile stops meeting the next one. Two keys deal with it:
preserveAxes: ["X"]snaps any vertex that ended up near the original minimum or maximum along X back onto it. The tolerance defaults to just overoffset, because the vertices the bevel pulled inward are otherwise the ones it fails to catch. It defaults to whateverassertAxesnames, since asserting an axis without preserving it is a guaranteed failure. PasspreserveAxes: []to really mean none.assertAxes: ["X"]throws the whole edit away and fails if the size along X moved by more thanassertTolerance(default1e-5Blender units) or the seam planarity on X broke. Nothing is written to the mesh.
Both are off by default. The answer always carries sizeBeforeBU, sizeAfterBU, sizeDeltaBU and sizeDeltaUU, and offSeamVerts and seamPlanarity for all three axes whatever is guarded. A guard decides what fails, never what is looked at.
Two defaults of Blender's own bevel are overridden on purpose: affect (which defaults to vertices) and material (which defaults to 0 and would drag every new face into the first material slot instead of inheriting).
Seam planarity: what a size check cannot see
A size check alone is blind to the failure it exists to catch. On Blender 4.4.0, a 1000 x 300 x 50 uu tile, bevelling the Y edge loops at offsetUU: 15 with the guards off:
| Measure | Before | After |
|---|---|---|
sizeDeltaUU |
none | [0.0, 0.0, 0.0], which says clean |
| Vertices off the X seam plane | 0 | 24 of 32, up to 15 uu inside |
A bounding box is pinned by its extremes. The surviving corner vertices hold the reported X length at exactly 1000 while most of the end cap has slid inward, so assertAxes: ["X"] alone would pass that and the tile would shear at every join. Both editing operations therefore measure planarity as well as extent, per axis:
| Field | Meaning |
|---|---|
bandVertsBefore and bandVertsAfter |
Vertices strictly inside the band next to a seam plane: off the plane, but close enough that the snap should have caught them. An increase is the shear, and the primary trigger |
movedOffSeam |
Vertices that were on the plane, survived the operation, and are now further than the band from it. Catches a survivor that drifted past the band |
seamVertsRemoved |
Seam vertices the operation destroyed. Reported and never failed on: a bevel removes all 8 originals and rebuilds 8 in the same places, which is correct. It is here so movedOffSeam: 0 is never read as "the originals are fine" when they are gone |
onSeamBefore and onSeamAfter |
How many vertices lie on the plane, for reading the other three |
seamBand defaults to the snap tolerance, so "in the band" means exactly "the snap should have caught this". seamTolerance (default 1e-4 Blender units) is the separate test for "is this vertex on the plane". Warnings for unguarded axes appear only when no axis is guarded at all, and never for the selector's own axis, because axis: 'Y' means "bevel the Y extremes".
extrude_skirt
Duplicates a boundary loop and drops it along Z: depth (Blender units) or depthUU (exactly one), direction (down or up, default down) and flipNormals. It takes the same selector and the same preserveAxes, assertAxes, seamTolerance, seamBand and dryRun as bevel_edges, plus allowNonBoundary.
It is the safe tiling edit because it moves no existing vertex. The new loop is made in place and then translated by (0, 0, ±depth). The X and Y components are literal zeros, so the seam stays planar by construction. Measured on Blender 4.4.0 with a 10 x 3 unit grid tile, 8 boundary edges and depthUU: 15: dX 0.0, dY 0.0, dZ 0.15, minimum and maximum X unmoved, preserveSnappedVerts: 0, and no vertex near-but-off the X seam planes. The new side faces on the +Y boundary came back with normal (0, +1, 0), outward, at the default flipNormals: false.
Refusals:
- Non-boundary edges are refused, unless you pass
allowNonBoundary. Extruding an interior edge does not skirt the mesh. It duplicates the loop and splits it, and the seam is then invisible from outside. Runselect_edgesfirst and readinteriorEdges. assertAxescontaining Z is refused up front. A skirt grows Z by up todepth, so asserting it constant is a guaranteed failure.preserveTolerancedefaults to1e-6Blender units here, not to just over the offset. Nothing moves sideways, so a wide snap band would drag real interior vertices onto the seam and call that a fix.preserveSnappedVertsshould always be 0 andoffSeamVertsall zero. Anything else means the selection was not a clean boundary loop.
The new faces get whatever Blender copies from the source loop, which is not a skirt unwrap. Expect stretched texturing until you author the UVs. Nothing here does that for you.
FBX axis and scale for Unreal
Unreal writes and reads Up = +Z, Front = -Y, right-handed, in centimeters. Blender's FBX defaults do not match: they are axis_up='Y' and axis_forward='-Z'. Reading the GlobalSettings block of the binary FBX that Blender writes confirms the difference:
| Export setting | UpAxis | UpSign | FrontAxis | FrontSign | CoordAxis | CoordSign |
|---|---|---|---|---|---|---|
axis_up='Z', axis_forward='Y' (what the add-on pins) |
2 (Z) | +1 | 1 (Y) | -1 | 0 (X) | +1 |
| Blender's operator defaults | 1 (Y) | +1 | 2 (Z) | +1 | 0 (X) | +1 |
The first row is Unreal's own system. It is also the pair Blender's exporter table marks as Blender's own system, so it is the identity mapping in both directions: no rotation is applied, in either direction.
Scale. The add-on exports with apply_unit_scale=True and apply_scale_options='FBX_SCALE_NONE'. Blender bakes a factor of 100 into the geometry and writes UnitScaleFactor=1.0, so a file holds centimeter magnitudes. One Blender unit is 100 Unreal units, and a 1000 uu road is 10.0 Blender units.
Import takes no axis arguments, on purpose. Blender's manual-orientation option stays off, so the importer reads the axes out of the file and reverse-maps them. Passing axis arguments could only make it wrong.
Never call bpy.ops.object.transform_apply on a mesh headed for Unreal. Unreal exports a static mesh in its own local space. One "Apply All Transforms" bakes the round trip into the mesh, and anything that tiles shears. import_mesh warns about a non-identity transform for exactly this reason.
Two traps
The scene's unit scale silently rescales every export. Measured on Blender 4.4.0 with the add-on's own export options: the same 10 unit cube exported at a unit scale of 1.0 reimports at 10.0 units. Exported at a unit scale of 0.01 it reimports at 0.1 units with object scale 0.01. The file header's UnitScaleFactor is 1.0 both times, so the header does not give it away, and only the magnitudes do. scene_info reports unitSettings.scaleLength and warns when it is not 1.0.
obj.bound_box and obj.dimensions are caches, and they are stale right after a bmesh write. Measured on Blender 4.4.0: after bm.to_mesh(mesh) and mesh.update(), the mesh vertices span z -0.15 to 0.0 while obj.bound_box still reports 0.0 to 0.0 and obj.dimensions still reports z = 0. obj.update_tag() does not refresh them. Only bpy.context.view_layer.update() does. If you write your own script with run_python, refresh before you measure. The add-on's own operations read vertex data directly for bounds and call view_layer.update() for everything else.
Check a round trip before trusting it
- Run a no-op round trip first: export, reimport with no edits, then compare bounds, vertex count and material slot order, before putting real geometry through.
- Material slot order is worth diffing. If a round trip reorders slots the mesh is still valid but renders with swapped materials, and it looks like a texture bug, not a slot bug.
object_inforeportsmaterialSlotsin order. mesh_smooth_type='FACE'writes smoothing groups and normals, more information than the'OFF'default. Which of the two Unreal uses depends on the static mesh import option "Normal Import Method". If normals come back wrong, this is the first setting to change.
Why every call runs on Blender's main thread
Blender's data layer is not thread safe, and a write from another thread while the main thread is evaluating or drawing is a hard crash or silent corruption, with no Python traceback to catch. So the socket thread only accepts, reads, parses, queues and replies. Every bpy call runs on the main thread, drained from a queue by one timer registered at startup. The socket thread waits for the job to finish before it replies, so a caller is never told a file is on disk before Blender has written it. In a background Blender (blender -b) there are no timers, so jobs run inline and the accept loop owns the main thread. See MifBlender.
Generative operations
Five operations, the gen_* family, talk to a ComfyUI server you run yourself, at 127.0.0.1:8188 unless MIF_COMFY_HOST or the operation's host says otherwise. They are the only generative operations, and they run by default. Two switches turn them off:
- Untick Allow generative tools (ComfyUI) in the add-on preferences, which covers every file.
- Set the open file's project mode to
non-generativewith theproject_modeoperation. It is stored as a custom property on the file's scenes and kept when the file is saved.
Off, each is refused before it reads a parameter or contacts ComfyUI. ping reports the state, the sidebar has a generative row, and the call journal writes generative on every start record. The MCP server has its own switch, MIF_GENERATIVE=off. See Using MifBridge without generative AI.