Extensions
An extension is a local folder that adds your own tools, Unreal endpoints or Blender ops to MifBridge without changing the plugin itself. Read this if you want to write one, or want to know what an installed one can and cannot do.
What an extension is
An extension is a folder on your disk. Nothing downloads, nothing asks a registry and nothing touches the network. It has up to three halves, and any of them may be absent:
| Half | What it adds | Where it runs |
|---|---|---|
| tools | MCP tools, grouped as one tool pack, that compose existing endpoints and Blender ops | the MCP server's Python process |
| native | new HTTP endpoints with C++ handlers | the Unreal Editor, inside the extension's own Unreal plugin |
| blender | new ops registered into MifBlender from a separate Blender add-on | Blender |
One rule shapes all of it: an extension reaches the editor only through the same dispatcher every built-in endpoint uses. The safety gate, write modes, the change cap, undo transactions, the crash journal, asset locks, the modal-dialog guard and describe_endpoint apply to an extension's endpoints without a second copy of any of them. An extension cannot step around the checks that protect your project.
Trust
The tools half is Python that runs inside the MCP server with your privileges. It is code you chose to install, like an editor plugin. The supported surface is the api object described below. Importing the server's private functions works, and is not a contract. The native half is C++ inside your editor, and the bridge cannot sandbox it: if its handler crashes the editor, the crash journal names the endpoint and its provider, as for a built-in.
No MCP tool installs or removes an extension. That is always your own act, from the command line.
Where extensions live
The extensions directory is MIF_EXTENSIONS_DIR (one or more folders, separated by your platform's path separator) or, when unset, ~/.mifbridge/extensions. Every folder in it is one extension. Setting MIF_EXTENSIONS=off in the server's environment loads none.
A removed extension is moved to <extensions dir>/.removed/<name>-<timestamp>, never deleted. The loader skips every folder whose name starts with ..
The command line: mifext.py
mifext.py ships with the MCP server.
| Command | Does |
|---|---|
new <name> [--prefix p_] [--native] [--dir D] |
writes a working skeleton: manifest, a tools module with one tool, and a README. With --native it adds a native plugin with a .uplugin, a Build.cs and a module that registers one read-only endpoint |
validate <folder> |
every check the loader makes, plus the native half's static checks. It prints what it checked and what it could not check, and exits non-zero on any error and on a folder that declares nothing |
install <folder> [--force] |
validates, then copies into the first extensions directory. It refuses to replace an installed one without --force, which moves the old one to .removed/ |
list |
installed extensions, their tools and native endpoints, and any that would fail to load and why |
remove <name> |
moves the folder to .removed/ |
install-native <folder> --project <dir> [--force] |
copies the native plugin into the project's Plugins/ folder. It does not build: the next editor build compiles it |
install-blender <folder> --addons <dir> |
copies the Blender add-on into a Blender add-ons folder. You enable it in Blender's preferences, after MifBlender |
The read-only MCP tool mif_extensions reports what loaded, its tools and pack, what failed and why, and for each native endpoint or Blender op whether the connected editor or Blender has it registered.
The tools half
The manifest
Each extension has a mifextension.json:
{
"apiVersion": 1,
"name": "acme_tools",
"version": "0.1.0",
"description": "One sentence: what the pack is for.",
"prefix": "acme_",
"pack": {"title": "Acme tools", "backend": "unreal", "rules": ["optional short rules for the pack"]},
"module": "tools.py",
"tools": ["acme_level_summary"],
"native": {"plugin": "AcmeExtension", "provider": "AcmeExtension", "endpoints": ["acme_tag_actors"]}
}
apiVersionis the contract the module was written against. The server speaks 1, and any other value is refused by name with the versions it speaks.nameis lowercase,[a-z][a-z0-9_]{1,31}, and is also the folder's name. The tool pack is calledext_<name>.prefixstarts every tool and every native endpoint name:[a-z][a-z0-9]{1,15}_. These prefixes are refused:bl_,mif_,kr_,ue_,ext_, and any prefix a built-in tool or endpoint already uses (the validator lists the clashes). That keeps two extensions, or an extension and a later MifBridge release, from fighting over a name.toolsdeclares the tools the module registers. A module that registers a tool the manifest does not list, or lists one it never registers, fails to load. The manifest is what you review before installing.nativeis optional: the plugin folder undernative/, the provider string its C++ registers with, and the endpoints it registers.blenderis optional, and described in its own section below.
The module and the api object
MIF_EXTENSION_API = 1
def register(api):
@api.tool
def acme_level_summary(class_filter: str = "") -> dict:
"Count the open level's actors by class."
res = api.call("list_level_actors", classFilter=class_filter, limit=1000)
if not api.ok(res):
return res
...
| api | What it does |
|---|---|
api.version |
the api version in force (1) |
api.extension |
the manifest, read-only |
api.tool(fn) |
registers fn as an MCP tool. Its name must carry the prefix and be in the manifest, and its parameters must be annotated, since the schema is built from them and the server's unknown-argument check refuses a typo against it |
api.call(endpoint, **body) |
calls one editor endpoint by its own camelCase keys, through the server's normal request path (token, session, request id, timeouts, busy-not-down answers) |
api.blender(op, **params) |
calls one MifBlender op through the add-on socket |
api.ok(result) |
false when result carries error. Failure is the presence of error |
What a tool cannot do
api.call is an HTTP request like any tool's, so the editor's gate applies. A save in scratch mode is refused exactly as save_package is. An extension cannot change the write mode (no endpoint does), cannot raise the change cap unless you ask, and cannot install another extension.
Failure modes
| Failure | What happens |
|---|---|
a manifest that does not parse, a missing field, an unknown apiVersion, a bad name or prefix |
that extension is skipped and mif_extensions lists it under failed[] with the reason. The server starts |
the module raises on import or in register |
the same. Nothing it registered before the exception is kept: registration is staged, then committed |
| a tool name already registered (a built-in, or another extension's) | that extension is skipped whole, since a half-loaded pack is worse than none |
two folders with the same name |
the first by directory order loads. The second is failed[], naming the first |
MIF_EXTENSIONS=off |
nothing is loaded, and mif_extensions says so |
| a native endpoint the manifest names and the editor does not register | mif_extensions reports it missing with the plugin to build or enable. A call to it fails with unknown endpoint |
An extension's tools join the tool packs, so find_tools, mif_call, mif_enable_pack, MIF_TOOL_PACKS and mif_help see them as they see built-in packs. You can also call a native endpoint with mif_call when no tool was written for it. The reply's providedBy names the extension.
The native half
A native extension is an Unreal plugin that registers endpoints at module startup through MifBridgeExtensionApi.h.
#define MIFBRIDGE_EXTENSION_API_VERSION 2
struct FExtensionParam { FString Name; EExtensionParamType Type; bool bRequired; FString Doc; };
struct FExtensionEndpointDesc {
FString Name, Provider, Summary;
EEndpointBucket Bucket; // ReadOnly | SelfManaged | Transacted
EExtensionSafety Safety; // Normal | Unsafe: the write gate, independent of the bucket
FString UnsafeReason; // required when Unsafe: said in the refusal
TArray<FExtensionParam> Params; // the accepted keys; an empty array means "takes no parameters"
TArray<FString> ChangeCapKeys; // parameters whose values are the targets the call changes
FExternalHandler Handler;
};
bool RegisterExtensionEndpoint(FExtensionEndpointDesc Desc, FString* OutError = nullptr);
The plugin's side. Its .uplugin lists MifBridge under Plugins. Its module's Build.cs depends on MifBridge and Json. Its loading phase is Default or earlier. It registers in StartupModule and calls UnregisterExternalEndpoints(Provider) in ShutdownModule. Registration after the bridge's route table is live is refused, because routes bind once at server start.
Versioning. The plugin compiles the header's version into its call. The bridge speaks versions 1 and 2, and refuses any other with both numbers in the error. A plugin built against a newer header than the running bridge fails loudly at registration instead of misreading a struct. self_audit reports extensionApi {version, oldestSupported}, and each endpoint row reports the version it registered under.
What registration checks
- The name is lowercase snake case,
[a-z][a-z0-9_]*, and is not a built-in's or another provider's. - The provider and handler are present.
- Each parameter has a unique, non-empty name, and none is called
op(that isbatch's routing key). - An
Unsafeendpoint has anUnsafeReason. - Every
ChangeCapKeysentry is a declared parameter. - Registration happens on the game thread.
The wrapped handler
When an endpoint registers, the bridge stores its handler inside a wrapper, so the normal dispatcher and batch both run the same wrapped handler:
- Unknown keys are refused with the bridge's own unknown-parameter words (case-insensitive, with
optolerated inside a batch). - Missing required keys and JSON types that do not match the declaration (string, number, integer, bool, array, object) are refused, naming the parameter. Nothing has run.
- The change cap counts the distinct values under
ChangeCapKeys(a string is one target, each entry of a list is one). Over the project's cap the call is refused with the cap's own message. Inside a batch that op alone is refused, and the refusal is journaled. A handler that learns its count only after reading the world callsRefuseOverChangeCapitself, before its first write. - The extension's handler runs.
- The reply is stamped
providedBy: <Provider>.
How an extension endpoint is covered
| Mechanism | How it applies |
|---|---|
| write modes and the gate | Unsafe makes scratch and read refuse the endpoint as they refuse save_package, and the refusal carries unsafeReason. Content under /Engine/ is protected as for every endpoint |
read mode |
works as for built-ins: it refuses what scratch refuses and every other write |
| undo | the bucket decides: Transacted gets one transaction around the call, cancelled on ok:false. ReadOnly gets none. SelfManaged opens its own and is kept out of batch |
batch |
its ops reach the wrapped handler and the gate. A SelfManaged extension endpoint is refused inside batch, as a compile-heavy built-in is |
| bulk previews | none in API version 1: over the cap the call is refused. Version 2 adds a planner, below |
| call journal | every call is journaled by endpoint name before it runs |
| asset locks, modal guard, scratch watch | applied to every endpoint, as for built-ins |
describe_endpoint |
answers status: params_declared, paramsSource: extension, acceptedParams, params[] (name, type, required, doc), acceptsNoParameters, and an extension block with the provider, API version, safety, unsafeReason and changeCapKeys |
self_audit |
reports each endpoint's provider and API version, writeGated for an Unsafe one, and externalProviders[] |
Native failure modes
| Failure | What happens |
|---|---|
| a plugin built against a newer header | registration is refused, the error names both versions, and its endpoints do not exist |
| a name clash with a built-in | refused. Built-ins win, so the extension must rename. The prefix rule makes this rare |
| the plugin is disabled or not built | its endpoints are absent from self_audit, and mif_extensions names the plugin |
| the handler crashes the editor | the crash journal names the endpoint and its provider |
| the handler mutates and then fails | a Transacted endpoint's undo record is cancelled as for built-ins, and kept when a parameter-type violation made the write. The reply carries error |
Bulk previews for native endpoints: API version 2
A built-in bulk write supports the bulk preview: dryRun: true lists every change and writes nothing, and a second call with the previewId commits exactly that list. API version 2 gives an extension endpoint the same handshake by registering a planner. A planner is a function that is given the request, fills in what the handler would write, and writes nothing itself. There is no second copy of the preview, the store, the drift check, the read-back or the rollback.
A planner fills:
Context: plan-level facts. A difference at commit time counts as drift.Changes: what the handler writes, in order.Unchanged: targets that already hold their value.Refused: a refusal flagged as blocking means the handler would refuse the whole call, so nopreviewIdis issued.Notes, and optionally a commit request.
It returns false, with an error, to refuse the request.
Version 2 supports one kind of change, a property. PlanExtensionPropertyWrite plans one property write exactly as set_property plans one in a batch, with the same resolvers, the same value import into a scratch copy, the same canonical export before and after, and the same refusals (a Blueprint's defaults, a class default object, a widget target, a selector). Transform, row, row-order and created-actor changes are not available to a planner yet, and a planner offering one is refused.
A change the planner builds itself is checked as the plan is read. A kind other than property, an op other than modify, a missing target or field, a missing before or after, or a Before that is not the property's live value fails the preview as the extension's defect. extensionPlanDefects[] names each, and nothing is stored.
| Answer | What an endpoint with a planner gives |
|---|---|
dryRun: true |
dryRun, endpoint, wouldCommit, summary, changes[] (kind property, target, field, before and after typed, plus beforeText and afterText), unchanged[], refused[], context[], previewId, expiresInSeconds, providedBy |
{previewId} |
committed, result (the handler's own answer), changes[] each with its readBackText and matches, and on a mismatch mismatches[], rolledBack and rollbackComplete |
| a plain call over the cap | the preview with capped: true, changeCap, nothingWritten, ok:false and the previewId to commit |
describe_endpoint |
acceptedParams with the three preview keys, and extension.previewable: true |
Registration refuses a planner on a ReadOnly endpoint (a preview plans a write) and a version-2 descriptor registered under API version 1. A version-1 plugin registers and runs unchanged on this bridge.
| Failure | What happens |
|---|---|
| the planner writes | a tripwire names what changed, the preview fails as a MifBridge-side defect report, and nothing is stored |
| the planner states a wrong before, or a kind version 2 does not support | refused before anything is stored, with extensionPlanDefects[] |
| the handler writes something the plan did not list | the commit reports it as alsoChanged |
| the handler writes a different value | a read-back mismatch: every change is put back and the request is planned a third time to prove it |
| the world moved between preview and commit | drift[], nothing written, the preview spent |
The Blender half
The tools half reaches every built-in Blender op through api.blender. A new op needs code inside Blender. Instead of forking MifBlender, a separate Blender add-on registers it into MifBlender's dispatcher. The rule is the native half's: one dispatcher. A registered op is answered by the same dispatcher as every built-in, so the undo step after each write, the change cap, batch, bl_describe_op, the op list in bl_status and the call journal apply to it.
MifBlender publishes its API as the module name mifblender_api when it starts, so an extension add-on imports it. It is absent until MifBlender is enabled.
import mifblender_api
mifblender_api.register_ops("AcmeBlender", "acme_", [
{"name": "acme_count_faces", "fn": op_count_faces, "keys": ["object"], "required": ["object"],
"capKeys": [], "undo": "read", "summary": "Count an object's faces."},
], api_version=1)
mifblender_api.unregister_ops("AcmeBlender") # in the add-on's unregister()
| Field | Meaning |
|---|---|
fn(params) |
called on Blender's main thread with the params already checked: no unknown key, every required key present. Raise mifblender_api.MifOpError to refuse. The message reaches the caller as error with no traceback. The reply is stamped providedBy |
keys, required |
the accepted keys and the ones that must be present. readBack, op, previewId and dryRun are reserved |
capKeys |
keys whose values are the data-blocks the call changes (a string is one target, a list is one per entry). They are counted by the change cap, alone and inside batch. Over the cap the call writes nothing and is refused with the cap's words. API version 1 offers no preview |
undo |
step (the default: one undo step is pushed after the op runs, on success or refusal), read (pushes none) or external (writes outside the scene, so the scene part is still a step). bl_describe_op answers it as undoClass, which workflow rollback reads |
| api_version | checked against the versions supported. Another is refused with both numbers |
Registration is all or nothing, since a half-registered provider is worse than none. These are refused: a provider registered twice, a prefix outside the same rule mifext.py uses, an op without the prefix, a built-in op's name or another provider's, a function that cannot be called, a reserved or repeated key, a required or capKeys entry that is not a key, and an unknown undo class or descriptor key.
mif_extensions reads the add-on's registration list and reports, per declared op, registered by <provider> (extension api vN) or missing, with what to do.
Load order and Blender failure modes
Blender enables add-ons in its own order. An extension add-on that finds mifblender_api missing should retry from a timer for ten seconds, then say in the console that MifBlender is not enabled. Disabling MifBlender removes the name. The extension's ops stay in memory but nothing dispatches them.
| Failure | What happens |
|---|---|
| the extension add-on is enabled before MifBlender | it retries for ten seconds, then says so in the console. mif_extensions reports its ops missing. Disabling then enabling the add-on registers them |
| an add-on written against a newer API | register_ops refuses with both version numbers, and nothing is registered |
| a name a later MifBlender ships as a built-in | registration refuses it from then on, and an op registered earlier is shadowed by the built-in |
the op raises something other than MifOpError |
the dispatcher's own handling: ok:false with the traceback, and the undo step pushed as for any refusal |
| the op writes a file | declare undo: "external", so a workflow's rollback says the file stays |
Not available yet
- Updating an installed extension from a newer folder in place. Today
install --forcereplaces it and keeps the old one in.removed/. - Bulk previews for an extension's Blender ops. Over the cap they are refused, as for a built-in with no plan.
- Transform, row, row-order and created-actor changes in a native planner.