UE-MCP

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:

  1. Rerun is safe — running the same flow again doesn't duplicate work or explode on "already exists" errors.
  2. 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:

EntityNatural key param
ActoractorLabel (or label shorthand on creates)
Asset (material, texture, mesh, datatable…)assetPath or path
Blueprint variableblueprintPath + variableName
Blueprint functionblueprintPath + functionName
Componentparent + componentName
Material parametermaterialPath + parameterName

Handlers without a natural key (e.g., execute_command, shell) cannot be idempotent or reversible — document them as such, do not emit rollback records.

onConflict — creates only

Create handlers accept an optional onConflict parameter controlling what happens when the natural key already resolves to an existing entity:

ValueBehavior
"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: true result 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.

Helpers

HandlerUtils.h provides:

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

// 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?)

// 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)

// Parameter extraction (Vec3 / Rotator / Color / Transform helpers)
RequireString, OptionalString, OptionalNumber, OptionalInt, OptionalBool
OptionalVec3, RequireVec3, OptionalRotator, RequireRotator, OptionalTransform
MCPVec3ToJsonObject, MCPRotatorToJsonObject, MCPLinearColorToJsonObject

HandlerAssetCreate.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 — document as non-reversible

Delete handlers are idempotent (deleting a non-existent thing is a no-op) but not reversible by default. Undoing a delete requires snapshotting the deleted entity beforehand, which is only worthwhile for high-value handlers.

auto Result = MCPSuccess();
if (NotFound) {
    Result->SetBoolField(TEXT("alreadyDeleted"), true);
} else {
    /* delete */
    Result->SetBoolField(TEXT("deleted"), true);
    // No rollback record — delete is not reversible by default.
}
return MCPResult(Result);

Non-convertible handlers

These handlers cannot meaningfully participate:

  • shell — arbitrary command execution
  • editor.execute_command — arbitrary console commands
  • editor.take_screenshot — side-effect with no natural inverse
  • editor.start_editor, editor.quit_editor, level.save, level.load — lifecycle operations

Conversion progress

CategoryDoneRemaining
Levelplace_actor, spawn_light, spawn_volume, move_actor, set_actor_material, set_light_properties, set_component_property, set_volume_properties, set_world_settings, add_component_to_actor, delete_actor
Assetduplicate_asset, rename_asset, move_asset, delete_asset, create_datatable, import_static_mesh, import_skeletal_mesh, import_animation, import_texture, set_mesh_material, set_texture_properties (partial), add_socket, remove_socketrecenter_pivot, reimport_*
Blueprintcreate_blueprint, add_variable, add_component, create_function, rename_function, delete_function, delete_node, delete_variable, remove_component, create_blueprint_interfaceset_variable_properties, set_node_property, add_node, connect_pins, set_class_default, set_variable_default, add_function_parameter
Materialcreate_material, create_material_instance, create_material_from_textureadd_material_expression, set_*, connect_expression, delete_expression
Animationcreate_anim_blueprint, create_montage, create_blendspace, create_sequenceadd_anim_notify, create_state_machine, add_state, add_transition, set_*, set_bone_keyframes
Audiocreate_sound_cue, create_metasound_source, spawn_ambient_sound
Foliagecreate_foliage_typeset_foliage_type_settings
Gameplaycreate_smart_object_definition, create_input_action, create_input_mapping_context, create_blackboard, create_behavior_tree, create_eqs_query, create_state_tree, create_game_mode/state/player_controller/player_state/hud (via CreateBlueprintWithParent), spawn_nav_modifier_volumeset_collision_profile, set_physics_enabled, set_body_properties, create_ai_perception_config
GAScreate_gameplay_effect, create_gameplay_ability, create_attribute_set, create_gameplay_cue, create_gameplay_cue_notifyadd_ability_tag, add_attribute, set_ability_tags, set_effect_modifier, add_ability_system_component
Niagaracreate_niagara_system, create_niagara_emitter, create_niagara_system_from_emitterspawn_niagara_at_location, set_niagara_parameter, add_emitter_to_system, set_emitter_property
PCGcreate_pcg_graph, spawn_pcg_volumeadd_pcg_node, connect_pcg_nodes, remove_pcg_node, set_pcg_node_settings
Sequencercreate_level_sequenceadd_track, sequence_control
Splinecreate_spline_actorset_spline_points
Widgetcreate_widget_blueprint, create_editor_utility_widget, create_editor_utility_blueprintset_widget_property, add_widget, remove_widget, move_widget
Landscape/Networking/Physics/Reflectioncreate_landscape, create_landscape_layer_info, set_landscape_material, create_enum (#251/#303), set_replicates, set_collision_profile, set_simulate_physics, set_mass_override, set_linear_damping

Every handler in the "Done" column is idempotent (checks for existing entity by natural key, returns ( existed: true ) on replay) and emits a rollback record where a paired inverse exists. Handlers in "Remaining" are either pure modifies that need before-state capture, or pure deletes that need snapshot-before-delete to be reversible. They still work; they just don't yet participate in rollback.