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_console, 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)

Patterns

Create with natural key

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, reuse it.
    if (!Label.IsEmpty())
    {
        for (TActorIterator<AActor> It(World); It; ++It)
        {
            if (It->GetActorLabel() == Label)
            {
                if (OnConflict == TEXT("error"))
                    return MCPError(FString::Printf(TEXT("Actor '%s' already exists"), *Label));

                auto Result = MCPSuccess();
                MCPSetExisted(Result);
                Result->SetStringField(TEXT("actorLabel"), Label);
                // No rollback record — nothing was created.
                return MCPResult(Result);
            }
        }
    }

    // Create path
    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);
}

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_console — 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_typecreate_foliage_layer, paint_foliage, set_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/Reflectionset_landscape_material, import_heightmap, sculpt_, paint_, networking setters, physics setters, create_gameplay_tag

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.