Handler Conventions
Handler Conventions documentation.
How mutating C++ handlers participate in idempotency (safe replay) and rollback (failure recovery).
Why
Flows mutate editor state. When a flow fails partway, the user wants two guarantees:
- Rerun is safe - running the same flow again doesn't duplicate work or explode on "already exists" errors.
- Failure is recoverable - the user can opt into automatic rollback that undoes completed mutations in reverse order.
Both are properties of each individual handler. The runner coordinates across handlers; each handler decides what the natural key is, how to detect existing state, and what the inverse operation looks like.
The contract
Every mutating handler (create, modify, delete) follows this shape:
Natural key
Each handler accepts a parameter identifying the entity it operates on. Examples:
| Entity | Natural key param |
|---|---|
| Actor | actorPath, or actorLabel (or label shorthand on creates) |
| Asset (material, texture, mesh, DataTable) | assetPath or path |
| Blueprint variable | blueprintPath + variableName |
| Blueprint function | blueprintPath + functionName |
| Component | parent + componentName |
| Material parameter | materialPath + parameterName |
Handlers without a natural key (such as execute_command) cannot be idempotent or reversible. They say so in the result and emit no rollback record.
Selecting an actor
Editor labels are not unique: copies of a Blueprint share a label, so the
first match a level iterator reaches depends on streaming order. Every
actor-targeting handler resolves its target through MCPResolveActor in
Public/HandlerActorResolve.h, never through a loop of its own:
FString ActorLabel;
if (auto Err = RequireStringAlt(Params, TEXT("actorLabel"), TEXT("actorPath"), ActorLabel)) return Err;
REQUIRE_EDITOR_WORLD(World);
TSharedPtr<FJsonValue> ActorErr;
AActor* Actor = MCPResolveActor(World, Params, ActorErr);
if (!Actor) return ActorErr;
ActorLabel = Actor->GetActorLabel();The rules the resolver enforces, so no handler restates them:
actorPathis the unambiguous selector and wins whenever it is given. A path that names nothing is an error about the path, not a quiet fall-through to the label.- A label matching more than one actor is refused with a structured error:
ambiguous: true,matchCount, and acandidates[]array carrying each actor'sactorPath, label, class, folder and location. There is no override that picks one anyway. - An action whose answer is genuinely plural (an ignore list, a selection, a
batch) uses
MCPCollectActorsByTokenand acts on every match instead. - An action naming its actor something else passes an
FMCPActorSelector; the convention is that the path key is the label key with "Label" swapped for "Path" (childLabel/childPath).
Every handler that returns an actor also returns its actorPath, so the round
trip from a read into the next write is a copy of one field.
onConflict - creates only
Create handlers accept an optional onConflict parameter controlling what happens when the natural key already resolves to an existing entity:
| Value | Behavior |
|---|---|
"skip" (default) | Return the existing entity, set existed: true, no rollback |
"update" | Reconcile the existing entity to the desired state (if applicable), set updated: true |
"error" | Return an MCPError ("already exists") |
Return shape
Creates and modifies populate one of:
{ "success": true, "created": true, "existed": false, /* entity fields */ }
{ "success": true, "created": false, "existed": true, /* entity fields */ }
{ "success": true, "updated": true, /* entity fields */ }Deletes return:
{ "success": true, "deleted": true } /* actually removed something */
{ "success": true, "alreadyDeleted": true } /* nothing to do */Rollback record
On a successful mutation that actually changed state, the handler attaches a rollback record naming the inverse handler and the payload needed to call it:
// In the handler, after a successful create:
TSharedPtr<FJsonObject> Payload = MakeShared<FJsonObject>();
Payload->SetStringField(TEXT("actorLabel"), NewActor->GetActorLabel());
MCPSetRollback(Result, TEXT("delete_actor"), Payload);The TS bridge lifts the rollback field onto TaskResult.rollback. When rollback_on_failure: true is set on a flow and a later step fails, flowkit invokes these records in reverse order.
Key rules:
- Only emit a rollback record when the handler actually mutated state. An
existed: trueresult means nothing was changed, so there's nothing to undo - do NOT emit a record. - The inverse must be another registered handler. Don't invent bespoke inverse handlers unless necessary; for creates, it's almost always the paired
delete_X. For modifies, it's the same handler called with the previous value (self-inverse). - Modifies capture the previous value before mutation. The rollback payload restores exactly that value.
- A record on a
success: falsebody describes the part that landed, and only that part. A handler that partly applied a mutation before giving up may attach an inverse for what it did write, and a few do. flowkit collects it like any other record and invokes it FIRST whenrollback_on_failureis armed, so build the payload from what actually applied rather than from what was asked for. Withoutrollback_on_failurenothing replays it and it arrives onsteps[i].partialWriteRollbackand in the step's error text for a caller to run. Build the record the same way either way. See docs/flows.md.
Idempotency, and why a re-run must not fail
A content mutation asked for twice REPORTS the second time rather than failing it. success: true with the marker that says nothing changed - alreadyExists, alreadyRemoved, existed, unchanged - and no rollback record, because nothing was mutated to undo. The caller asked for a state and the state holds.
Lifecycle actions are the exception, and they answer differently. editor(start_editor) on an editor that is already up, editor(stop_editor) with nothing running, and both halves of editor(play_in_editor) report success: false and carry alreadyRunning or alreadyStopped beside it. The call did not start, stop or play anything, and saying otherwise would be the handler reporting a success for work it did not do. The marker is what lets a caller tell that apart from a real failure, which is the job the marker exists for; the verdict stays honest.
This is not cosmetic for a content mutation. A success: false body fails the flow step that ran it and stops the run, so an asset create that treats "it already exists" as an error aborts every flow that makes sure of something before doing work, on the common path where it was already sure. MCPError is for a call that could not do what it was asked; being handed a state that already holds is not that.
Where the answer genuinely is a failure, as with the lifecycle actions above, the flow absorbs it rather than the handler hiding it: a step marks itself ignore_failure: true and the run continues with the failure still recorded. See docs/flows.md. That is the right layer, because only the flow author knows whether a particular step failing is expected.
Helpers
HandlerUtils.h includes every shared helper header; among them:
MCPSuccess() // { success: true }
MCPError(Message) // { success: false, error }
MCPResult(Obj) // wrap FJsonObject as FJsonValue
MCPSetCreated(Result) // { created: true, existed: false }
MCPSetExisted(Result) // { created: false, existed: true }
MCPSetUpdated(Result) // { updated: true }
MCPSetRollback(Result, InverseMethod, Payload)
MCPSetDeleteAssetRollback(Result, AssetPath) // shorthand for delete_asset rollback
MCPSetNoRollback(Result, Reason) // { rollbackPossible: false, rollbackNote }
// The reason is required: see
// "rollbackPossible" below.
MCPSetIdempotencyUnobservable(Result, Reason) // { idempotencyObservable: false, idempotencyNote }
// The same statement, one
// convention over: this call
// cannot read whether it changed
// anything, and here is why.
// Existence probes - return a ready-to-return Existed/Error JSON value
// on hit, an unset shared pointer on miss.
MCPCheckAssetExists(PackagePath, Name, OnConflict, FriendlyType?)
MCPCheckActorLabelExists(World, Label, OnConflict, FriendlyType?)
// Protected mount guardrail. True for /Engine/, /Memory/, /Temp/ and anything
// containing /Script/. Call it from every handler that deletes, moves, renames
// or writes an asset. Shared, never copied: see "File-local helpers and the
// unity build" in development.md.
MCPIsProtectedAssetPath(Path)
// Actor lookup
FindActorByLabel(World, Label) // canonical label lookup
FindActorByLabelOrName(World, Token) // PIE: label OR internal name
FindActorByLabelOrPath(World, Label, Path) // get_actor_details: one of two
FindActorByLabelNameOrPath(World, Token) // PIE invoke: any of three
// Blueprint CDO load + cast with structured error
LoadBlueprintCDO<TActor>(Path, OutError)
// Class name resolution - one shared path for every string-to-UClass lookup.
// Tolerates the C++ type prefix in both directions (UMyConfig <-> MyConfig),
// object paths, Module.Class shorthand, and Blueprint generated classes.
MCPResolveClass(Spec, bAllowLoad?) // UClass* or nullptr
MCPResolveClassOfType(Spec, Base, bAllowLoad?) // constrained to a base class
FindClassByShortName(Name) // thin wrapper over MCPResolveClass
MCPClassNotFoundError(Spec, ParamName?) // lists tried spellings + suggestions
MCPCheckClassUsable(Spec, Class, Base?, bConcrete?) // abstract / deprecated /
// wrong_base reported separately
// Parameter extraction (Vec3 / Rotator / Color / Transform helpers)
RequireString, OptionalString, OptionalNumber, OptionalInt, OptionalBool
OptionalVec3, RequireVec3, OptionalRotator, RequireRotator, OptionalTransform
// `args` in any of its forms: a UFUNCTION's name -> value map, or a script's
// positional strings. Return an error value for a form the kind cannot use.
MCPReadFunctionArgs(Params, Key, OutMap), MCPNormalizeFunctionArgs(Value, Label, OutMap)
MCPReadPythonArgs(Params, Key, OutStrings), MCPNormalizePythonArgs(Value, Label, OutStrings)
MCPVec3ToJsonObject, MCPRotatorToJsonObject, MCPLinearColorToJsonObjectHandlerAssetCreate.h adds:
// Probe-then-create using AssetTools. Returns FMCPAssetCreate<T> with either
// an EarlyReturn JSON value (caller just returns it) or an Asset pointer.
// Two overloads: static class (TAsset::StaticClass()) or runtime UClass*.
MCPCreateAssetIdempotent<TAsset>(Name, PackagePath, OnConflict, Label, Factory)
MCPCreateAssetIdempotent<TAsset>(Name, PackagePath, OnConflict, Label, UClass*, Factory)
// Probe-then-create via raw NewObject<> on a fresh UPackage + AssetCreated.
// Used by AnimSequence / AnimComposite / LevelSequence / PoseSearchDatabase /
// NiagaraSystem-from-spec where AssetTools.CreateAsset isn't the right entry
// point (factory configuration must happen on the constructed object first).
MCPCreateAssetIdempotentNewObject<TAsset>(Name, PackagePath, OnConflict, Label)Patterns
Spawn an actor with natural-key idempotency
TSharedPtr<FJsonValue> FLevelHandlers::PlaceActor(const TSharedPtr<FJsonObject>& Params)
{
FString Label = OptionalString(Params, TEXT("label"));
const FString OnConflict = OptionalString(Params, TEXT("onConflict"), TEXT("skip"));
REQUIRE_EDITOR_WORLD(World);
// Idempotency: if an actor with this label exists, return Existed JSON.
if (auto Existing = MCPCheckActorLabelExists(World, Label, OnConflict, TEXT("Actor")))
{
return Existing;
}
AActor* NewActor = /* spawn */;
if (Label.IsEmpty()) Label = NewActor->GetActorLabel();
auto Result = MCPSuccess();
MCPSetCreated(Result);
Result->SetStringField(TEXT("actorLabel"), Label);
TSharedPtr<FJsonObject> Payload = MakeShared<FJsonObject>();
Payload->SetStringField(TEXT("actorLabel"), Label);
MCPSetRollback(Result, TEXT("delete_actor"), Payload);
return MCPResult(Result);
}Create an asset with natural-key idempotency
TSharedPtr<FJsonValue> FMaterialHandlers::CreateMaterial(const TSharedPtr<FJsonObject>& Params)
{
FString Name;
if (auto Err = RequireString(Params, TEXT("name"), Name)) return Err;
const FString PackagePath = OptionalString(Params, TEXT("packagePath"), TEXT("/Game/Materials"));
const FString OnConflict = OptionalString(Params, TEXT("onConflict"), TEXT("skip"));
UMaterialFactoryNew* Factory = NewObject<UMaterialFactoryNew>();
auto Created = MCPCreateAssetIdempotent<UMaterial>(Name, PackagePath, OnConflict, TEXT("Material"), Factory);
if (Created.EarlyReturn) return Created.EarlyReturn; // Existed or Error
SaveAssetPackage(Created.Asset);
const FString AssetPath = Created.Asset->GetPathName();
auto Result = MCPSuccess();
MCPSetCreated(Result);
Result->SetStringField(TEXT("path"), AssetPath);
Result->SetStringField(TEXT("name"), Name);
Result->SetStringField(TEXT("packagePath"), PackagePath);
MCPSetDeleteAssetRollback(Result, AssetPath);
return MCPResult(Result);
}Modify with before-state capture
TSharedPtr<FJsonValue> FLevelHandlers::SetActorMaterial(const TSharedPtr<FJsonObject>& Params)
{
// Capture previous material BEFORE changing
FString PreviousMaterialPath;
if (UMaterialInterface* Prev = PrimComp->GetMaterial(SlotIndex))
{
PreviousMaterialPath = Prev->GetPathName();
}
PrimComp->SetMaterial(SlotIndex, NewMaterial);
auto Result = MCPSuccess();
MCPSetUpdated(Result);
TSharedPtr<FJsonObject> Payload = MakeShared<FJsonObject>();
Payload->SetStringField(TEXT("actorLabel"), ActorLabel);
Payload->SetNumberField(TEXT("slotIndex"), SlotIndex);
Payload->SetStringField(TEXT("materialPath"), PreviousMaterialPath);
MCPSetRollback(Result, TEXT("set_actor_material"), Payload);
return MCPResult(Result);
}Delete - reversible where the state can be captured, and explicit where it cannot
Delete handlers are idempotent: deleting a thing that is not there is a no-op, not an error.
A delete that captures enough of the entity beforehand to recreate it emits a rollback record like any other mutation. A delete that cannot must say so in the result, because a body with no rollback field does not tell a caller or the conventions audit whether that was a decision or an oversight.
auto Result = MCPSuccess();
if (NotFound) {
Result->SetBoolField(TEXT("alreadyDeleted"), true);
return MCPResult(Result);
}
/* capture what recreating it would need, then delete */
Result->SetBoolField(TEXT("deleted"), true);
// Either the inverse:
MCPSetRollback(Result, TEXT("add_socket"), Payload);
// Or the reason there is not one, which is a call rather than a comment:
MCPSetNoRollback(Result,
TEXT("The socket carried per-instance overrides on three meshes and this handler reads none of them, "
"so add_socket would put back a socket that is not the one removed."));
return MCPResult(Result);Saying nothing is not an option: tests/unit/handler-conventions.test.ts fails on a mutation that emits neither.
rollbackPossible - stating that there is no inverse
The counterpart to MCPSetRollback, and the field the conventions audit gates on. MCPSetNoRollback(Result, Reason) writes both halves together:
{
"success": true,
"updated": true,
"rollbackPossible": false,
"rollbackNote": "A one-shot sound is an event, not a state. It is already audible by the time this returns and nothing changed on disk or in the level, so there is nothing to undo and no action that would undo it. Calling again plays it again."
}Two rules:
- The note is required.
rollbackPossible: falseon its own tells a caller that recovery is off the table without telling it why, which is the half that decides what the caller does next.MCPSetNoRollbacktakes the reason as an argument so the pair cannot come apart. - Say what was changed and what call would have to exist. "No inverse" is not a note. "The offset was baked into the mesh descriptions and committed, and the bridge has no action that translates mesh vertices, so nothing can add it back" is: it names the change, names the missing capability, and tells the next person what would close it.
A handler that emits both a rollback record and rollbackPossible: false is a contradiction, and the audit reports it as one.
Batch handlers - preflight, then per-item results
A batch handler takes one bounded array and does N of something. It has two obligations the single-item version does not.
Preflight the whole request before writing anything. Resolve every class, validate every name and destination, reject duplicate targets, and apply every requested value to a transient copy of the target. A descriptor that cannot possibly work should reject the entire request while it is still free to do so. asset(bulk_upsert_data_assets) does this by duplicating the existing asset (or constructing a fresh instance) into the transient package and running the real property writes against that copy first, so a typo in a property path cannot leave half a batch written.
Report per item once you start writing. Past the preflight boundary, a failure must not abort the response. Each entry in items[] carries its own status, success, and error, and the aggregate response still carries the rollback record naming everything that did land. Returning a bare error from inside the apply loop throws away exactly the information the caller needs to recover.
Batch handlers should also accept dryRun, which runs the full preflight and reports what each item would do (wouldCreate, wouldUpdate, wouldRemainUnchanged, wouldSkip) without dirtying or saving a package, and should size their array bound explicitly rather than accepting an unbounded request.
Handlers that cannot have an inverse
A handler with no natural key cannot be idempotent, and one whose effect is an event rather than a state cannot be undone. These are the standing cases:
editor(execute_command): arbitrary console commandseditor(capture_screenshot): produces a file, and deleting a file that regenerates on demand is not an undoeditor(start_editor),editor(stop_editor): process lifecyclelevel(save): writing packages to disk has no inverse callasset(reimport): the previous import is not recoverable from the asset it replaced
Each still owes the caller rollbackPossible: false and a note in the response body. There is no allowlist.
Input mapping persistence
gameplay(add_imc_mapping), remove_imc_mapping, set_imc_mapping_key,
set_imc_mapping_action, and set_mapping_modifiers save their Input Mapping
Context by default. The asset(add_input_mapping) and remove_input_mapping
aliases accept the same optional save boolean. save=false keeps the edit in
memory; inspect saved, persisted, packageDirty, and persistError before
relying on it. With save=true, a read-only, protected, or unmounted context is
refused before anything changes (reason: package_not_writable). A save that
still fails reports failure even though the in-memory edit succeeded. An
unchanged call with save=true saves pending edits, including edits from an
earlier save=false call, and skips the write when nothing is pending. Rollback
preserves the original call's save choice, so undoing a deferred edit does not
save other pending edits.
Declaring parameters with a spec
A handler declares its parameters at registration, and the TypeScript surface is generated from them. The mechanism, and how the TS surface is generated from it, is in Architecture: parameter specs. What it asks of the handler:
- Read the declared names, and only those. The registry renames each alias to its parameter before the handler runs, so a spec'd handler calls
RequireString(Params, TEXT("assetPath"), ...), neverRequireStringAltwith the alias. A second spelling belongs in the spec as.Alias(TEXT("path")). - Read every parameter before anything can fail.
UE.MCP.Bridge.HandlerSpec.Contractcalls each spec'd handler with every declared parameter pointing at an asset that does not exist, and asserts the read set equals the spec. A parameter read after the asset load is never reached, and fails the test as a declared parameter the handler does not read. - Exempt a handler the contract call could let write. Every string the contract test sends is the same missing asset path, so a create handler is only safe to call when something those values fail (an unresolvable class or struct, a name holding
/, an invalid package name, a missing source file) refuses the call before anything is created. A handler that would create a folder, a package or a file from them, spawn an actor, save, or run something, registers withMCPSpec::ContractExempt(TEXT("reason"))as its last argument. It is still spec'd and generates its surface, andtests/unit/handler-spec-exempt.test.tsreads its source instead of calling it, so it has to read every parameter through aHandlerUtils.hhelper with a literal key (or through a function it handsParamsto that does), and read nothing else. - A required choice is a choice, not two optionals.
actorLabel OR actorPathisMCPSpec::ExactlyOne(( ( TEXT("actorLabel") ), ( TEXT("actorPath") ) )), andsettings OR propertyName + propertyValueputs the pair in one branch. Every name in a choice is declared optional; the choice is what is required. The contract test calls the handler once per branch, sending only that branch's names, so each branch has to be read on its own path.MCPSpec::AtLeastOneis for selector sets where several may combine; declare a required parameter before its first member, since the clause lists the group's names with slashes. - Declare the value's real shape. A colour is
EMCPParamType::Color. A number that may also be a colour is.Or(EMCPParamType::Color), a reference that null clears is.Nullable(), a flag with one legal value is.Literal(false), and an array of objects declares its element with.Items(EMCPParamType::Object).WithFields(( MCPParam::RequiredField(...), MCPParam::OptionalField(...) )). Elements of several shapes picked by one field are.Tagged(TEXT("op"), ( MCPParam::Variant(...), ... )), and anAnyvalue that takes a map, an entry list, a string list or a string is.OneOfForms(( ... )), read throughMCPReadFunctionArgsorMCPReadPythonArgs. The contract test sends a literal's own value and a union's first type. - Declare what the handler reads. A parameter the handler never reads does not belong in the spec, whatever the surface used to advertise.
- A key a shared helper reads only on some paths is read ahead.
MCPReadParamsAhead(Params, ( ... ))at the top of the handler names every declared key, so the read set is the spec whatever path a call takes; the helpers still read each key where they use it. - A timeout override takes the spec too.
RegisterHandlerWithTimeout(name, fn, seconds, ( ... ))registers both, and the TS action spreadsspecBp(...)and adds itstimeoutMs. - Never a routing name.
action,timeoutMs,select,omit,editorandtoEditorare refused at registration and again by the generator. - Change a spec, then re-record.
npm run specs:recordagainsttests/ue_mcp, thennpm run specs:generate, and commit the recording and the generated module together with the C++.
Checking a handler
node scripts/audit-handler-conventions.mjs # counts, plus the offenders by name
node scripts/audit-handler-conventions.mjs --json # every handler, one row eachtests/unit/handler-conventions.test.ts pins the counts. Two are flat rules at
zero: a mutation that emits neither a rollback record nor rollbackPossible: false, and a mutation with no idempotency answer, each fail the suite. The
count of mutations without a rollback record is a ratchet: if it goes up, a new
handler skipped the convention; if it goes down, lower the baseline in the test
and commit it. It stays above zero because not every change has an inverse.
The audit is source-level. It proves a rollback is emitted, not that it is correct; the live tests check correctness.