// Split from AnimationHandlers.cpp to keep that file under 3k lines.
// All functions below are still members of FAnimationHandlers - this file is a
// translation-unit partition, not a new class. Handler registration
// stays in AnimationHandlers.cpp::RegisterHandlers.

#include "AnimationHandlers.h"
#include "HandlerRegistry.h"
#include "HandlerUtils.h"
#include "HandlerAssetCreate.h"
#include "Engine/Blueprint.h"
#include "Animation/AnimBlueprint.h"
#include "Animation/AnimMontage.h"
#include "Animation/AnimSequence.h"
#include "AnimationBlueprintLibrary.h"
#include "Kismet2/BlueprintEditorUtils.h"
#include "Kismet2/KismetEditorUtilities.h"
#include "Factories/AnimBlueprintFactory.h"
#include "AssetToolsModule.h"
#include "IAssetTools.h"
#include "EdGraph/EdGraph.h"
#include "EdGraph/EdGraphNode.h"
#include "AnimGraphNode_StateMachine.h"
#include "AnimationStateMachineGraph.h"
#include "AnimationTransitionGraph.h"
#include "AnimStateNode.h"
#include "AnimStateTransitionNode.h"
#include "AnimGraphNode_TransitionResult.h"
#include "K2Node_VariableGet.h"
#include "K2Node_CallFunction.h"
#include "EdGraphSchema_K2.h"
#include "Kismet/KismetMathLibrary.h"
#include "RetargetEditor/IKRetargeterController.h"
#include "RetargetEditor/IKRetargetBatchOperation.h"
#include "Retargeter/IKRetargeter.h"
#include "AnimGraphNode_SequencePlayer.h"
#include "AnimGraphNode_BlendSpacePlayer.h"
#include "Rig/IKRigDefinition.h"
#include "RigEditor/IKRigController.h"
#if UE_MCP_HAS_5_8_API
#include "Rig/Solvers/IKRigFullBodyIK.h"
#include "Retargeter/IKRetargetChainMapping.h"
#include "Retargeter/IKRetargetOps.h"
#include "JsonObjectConverter.h"
#include "Retargeter/IKRetargetProcessor.h"
#endif
#include "PoseSearch/PoseSearchDatabase.h"
#include "PoseSearch/PoseSearchSchema.h"
#include "HandlerPoseSearchSchema.h"
#include "PoseSearch/PoseSearchDerivedData.h"
#include "AssetRegistry/AssetRegistryModule.h"
#include "Animation/AnimComposite.h"
#include "Animation/AnimSequenceBase.h"
#include "Animation/BlendSpace.h"
#include "Animation/Skeleton.h"
#include "MCPEngineCompat.h"
#include "UObject/Package.h"
#include "Misc/PackageName.h"
#include "Runtime/Launch/Resources/Version.h"
#include "UObject/SavePackage.h"
#include "UObject/UnrealType.h"
#include "UObject/UObjectGlobals.h"
#include "EditorAssetLibrary.h"
#include "Editor.h"
#include "ScopedTransaction.h"
#include "Dom/JsonObject.h"
#include "Dom/JsonValue.h"


// UPoseSearchDatabase's GetNumAnimationAssets / GetDatabaseAnimationAsset pair
// is 5.5 and newer. 5.4 exposes the clip list as the AnimationAssets array of
// FInstancedStruct with GetAnimationAssetBase, which is what the #else arms
// below are written against.
#define UE_MCP_HAS_POSESEARCH_DATABASE_ASSET_API (ENGINE_MAJOR_VERSION > 5 || (ENGINE_MAJOR_VERSION == 5 && ENGINE_MINOR_VERSION >= 5))

#if UE_MCP_HAS_5_8_API
static TSharedPtr<FJsonObject> AnimationVectorToJson(const FVector& Value)
{
	TSharedPtr<FJsonObject> Json = MakeShared<FJsonObject>();
	Json->SetNumberField(TEXT("x"), Value.X);
	Json->SetNumberField(TEXT("y"), Value.Y);
	Json->SetNumberField(TEXT("z"), Value.Z);
	return Json;
}

static TSharedPtr<FJsonObject> AnimationQuaternionToJson(const FQuat& Value)
{
	TSharedPtr<FJsonObject> Json = MakeShared<FJsonObject>();
	Json->SetNumberField(TEXT("x"), Value.X);
	Json->SetNumberField(TEXT("y"), Value.Y);
	Json->SetNumberField(TEXT("z"), Value.Z);
	Json->SetNumberField(TEXT("w"), Value.W);
	return Json;
}

static TSharedPtr<FJsonObject> AnimationTransformToJson(const FTransform& Value)
{
	TSharedPtr<FJsonObject> Json = MakeShared<FJsonObject>();
	Json->SetObjectField(TEXT("translation"), AnimationVectorToJson(Value.GetTranslation()));
	Json->SetObjectField(TEXT("rotationQuaternion"), AnimationQuaternionToJson(Value.GetRotation()));
	Json->SetObjectField(TEXT("scale"), AnimationVectorToJson(Value.GetScale3D()));
	return Json;
}

// Processor initialization temporarily redirects editor-instance pointers on
// the serialized op stack. Restore them after validation and batch execution.
class FScopedBatchRetargetEditorInstanceRestore
{
	struct FState
	{
		FIKRetargetOpBase* Op = nullptr;
		FIKRetargetOpBase* OpEditorInstance = nullptr;
		FIKRetargetOpSettingsBase* Settings = nullptr;
		FIKRetargetOpSettingsBase* SettingsEditorInstance = nullptr;
	};

public:
	explicit FScopedBatchRetargetEditorInstanceRestore(UIKRetargeter* Retargeter)
	{
		if (!Retargeter) return;
		States.Reserve(Retargeter->GetRetargetOps().Num());
		for (const FInstancedStruct& OpStruct : Retargeter->GetRetargetOps())
		{
			FIKRetargetOpBase* Op = const_cast<FIKRetargetOpBase*>(OpStruct.GetPtr<FIKRetargetOpBase>());
			if (!Op) continue;
			FIKRetargetOpSettingsBase* Settings = Op->GetSettings();
			States.Add({Op, Op->EditorInstance, Settings, Settings ? Settings->EditorInstance : nullptr});
		}
	}

	~FScopedBatchRetargetEditorInstanceRestore()
	{
		for (const FState& State : States)
		{
			State.Op->EditorInstance = State.OpEditorInstance;
			if (State.Settings) State.Settings->EditorInstance = State.SettingsEditorInstance;
		}
	}

private:
	TArray<FState> States;
};
#endif

static int32 GetPoseSearchAnimationAssetCount(const UPoseSearchDatabase* Database)
{
#if UE_MCP_HAS_POSESEARCH_DATABASE_ASSET_API
	return Database->GetNumAnimationAssets();
#else
	return Database->AnimationAssets.Num();
#endif
}

// The database's clip list as it stands, in the exact shape set_pose_search_clips
// accepts. This is what makes a clip-list write reversible: the inverse is that
// same bulk setter replaying the captured list with clearExisting, so the payload
// has to carry every per-clip flag the setter can author, not just the paths.
static TArray<TSharedPtr<FJsonValue>> CapturePoseSearchClips(UPoseSearchDatabase* Database, int32& OutDroppedClipCount)
{
	OutDroppedClipCount = 0;
	TArray<TSharedPtr<FJsonValue>> Clips;
	const int32 Count = GetPoseSearchAnimationAssetCount(Database);
	for (int32 i = 0; i < Count; ++i)
	{
#if UE_MCP_HAS_POSESEARCH_DATABASE_ASSET_API
	#if UE_MCP_HAS_5_7_API
		const FPoseSearchDatabaseAnimationAssetBase* Entry = Database->GetDatabaseAnimationAsset(i);
	#else
		const FPoseSearchDatabaseAnimationAssetBase* Entry = Database->GetDatabaseAnimationAsset<FPoseSearchDatabaseAnimationAssetBase>(i);
	#endif
#else
		const FPoseSearchDatabaseAnimationAssetBase* Entry = Database->GetAnimationAssetBase(i);
#endif
		// An entry with no asset cannot be described to set_pose_search_clips,
		// which needs a sequencePath, so it is counted rather than silently lost.
		if (!Entry) { ++OutDroppedClipCount; continue; }
		const UObject* AnimAsset = Entry->GetAnimationAsset();
		if (!AnimAsset) { ++OutDroppedClipCount; continue; }

		TSharedPtr<FJsonObject> Clip = MakeShared<FJsonObject>();
		Clip->SetStringField(TEXT("sequencePath"), AnimAsset->GetPathName());
		Clip->SetBoolField(TEXT("enabled"), Entry->IsEnabled());
		Clip->SetBoolField(TEXT("disableReselection"), Entry->IsDisableReselection());
		switch (Entry->GetMirrorOption())
		{
		case EPoseSearchMirrorOption::MirroredOnly:
			Clip->SetStringField(TEXT("mirror"), TEXT("mirrored"));
			break;
		case EPoseSearchMirrorOption::UnmirroredAndMirrored:
			Clip->SetStringField(TEXT("mirror"), TEXT("both"));
			break;
		default:
			Clip->SetStringField(TEXT("mirror"), TEXT("original"));
			break;
		}
		const FFloatInterval Range = Entry->GetSamplingRange();
		Clip->SetNumberField(TEXT("sampleStart"), Range.Min);
		Clip->SetNumberField(TEXT("sampleEnd"), Range.Max);
		Clips.Add(MakeShared<FJsonValueObject>(Clip));
	}
	return Clips;
}

// Optional per-clip authoring flags (#684). Each is only applied when set, so a
// caller can tune one flag without disturbing the rest.
struct FPoseSearchClipFlags
{
	TOptional<bool> bEnabled;
	TOptional<bool> bDisableReselection;
	TOptional<EPoseSearchMirrorOption> MirrorOption;
	TOptional<FFloatInterval> SamplingRange;
};

// Parse clip flags out of a JSON object (the handler Params for a single add, or
// a per-entry object for the bulk setter). Recognises: enabled, disableReselection,
// mirror ("original"|"mirrored"|"both"), sampleStart / sampleEnd (seconds).
static FPoseSearchClipFlags ParsePoseSearchClipFlags(const TSharedPtr<FJsonObject>& Obj)
{
	FPoseSearchClipFlags Flags;
	bool BoolVal = false;
	if (Obj->TryGetBoolField(TEXT("enabled"), BoolVal)) Flags.bEnabled = BoolVal;
	if (Obj->TryGetBoolField(TEXT("disableReselection"), BoolVal)) Flags.bDisableReselection = BoolVal;

	FString Mirror;
	if (Obj->TryGetStringField(TEXT("mirror"), Mirror))
	{
		if (Mirror.Equals(TEXT("mirrored"), ESearchCase::IgnoreCase))
			Flags.MirrorOption = EPoseSearchMirrorOption::MirroredOnly;
		else if (Mirror.Equals(TEXT("both"), ESearchCase::IgnoreCase))
			Flags.MirrorOption = EPoseSearchMirrorOption::UnmirroredAndMirrored;
		else
			Flags.MirrorOption = EPoseSearchMirrorOption::UnmirroredOnly;
	}

	double SampleStart = 0.0, SampleEnd = 0.0;
	const bool bHasStart = Obj->TryGetNumberField(TEXT("sampleStart"), SampleStart);
	const bool bHasEnd = Obj->TryGetNumberField(TEXT("sampleEnd"), SampleEnd);
	if (bHasStart || bHasEnd)
	{
		Flags.SamplingRange = FFloatInterval((float)SampleStart, (float)SampleEnd);
	}
	return Flags;
}

#if UE_MCP_HAS_POSESEARCH_DATABASE_ASSET_API
static void ApplyPoseSearchClipFlags(FPoseSearchDatabaseAnimationAsset& Entry, const FPoseSearchClipFlags& Flags)
{
#if WITH_EDITORONLY_DATA
	if (Flags.bEnabled.IsSet()) Entry.bEnabled = Flags.bEnabled.GetValue();
	if (Flags.bDisableReselection.IsSet()) Entry.bDisableReselection = Flags.bDisableReselection.GetValue();
	if (Flags.MirrorOption.IsSet()) Entry.MirrorOption = Flags.MirrorOption.GetValue();
	if (Flags.SamplingRange.IsSet()) Entry.SamplingRange = Flags.SamplingRange.GetValue();
#endif
}
#endif

static bool AddPoseSearchAnimationAsset(UPoseSearchDatabase* Database, UObject* AnimAsset, const FPoseSearchClipFlags& Flags, FString& OutError)
{
	// PoseSearch accepts AnimSequence/Composite/Montage (all UAnimSequenceBase) and BlendSpace.
	if (!AnimAsset->IsA<UAnimSequenceBase>() && !AnimAsset->IsA<UBlendSpace>())
	{
		OutError = FString::Printf(TEXT("Animation asset type not supported by PoseSearch: %s"), *AnimAsset->GetClass()->GetName());
		return false;
	}

#if UE_MCP_HAS_POSESEARCH_DATABASE_ASSET_API
	// UE 5.7+ unified every clip type into FPoseSearchDatabaseAnimationAsset,
	// which holds any UObject anim asset directly.
	FPoseSearchDatabaseAnimationAsset Entry;
	Entry.AnimAsset = AnimAsset;
	ApplyPoseSearchClipFlags(Entry, Flags);
	Database->AddAnimationAsset(Entry);
	return true;
#else
	FInstancedStruct NewEntry;
	if (UAnimSequence* Sequence = Cast<UAnimSequence>(AnimAsset))
	{
		NewEntry.InitializeAs<FPoseSearchDatabaseSequence>();
		NewEntry.GetMutablePtr<FPoseSearchDatabaseSequence>()->Sequence = Sequence;
	}
	else if (UBlendSpace* BlendSpace = Cast<UBlendSpace>(AnimAsset))
	{
		NewEntry.InitializeAs<FPoseSearchDatabaseBlendSpace>();
		NewEntry.GetMutablePtr<FPoseSearchDatabaseBlendSpace>()->BlendSpace = BlendSpace;
	}
	else if (UAnimComposite* Composite = Cast<UAnimComposite>(AnimAsset))
	{
		NewEntry.InitializeAs<FPoseSearchDatabaseAnimComposite>();
		NewEntry.GetMutablePtr<FPoseSearchDatabaseAnimComposite>()->AnimComposite = Composite;
	}
	else if (UAnimMontage* Montage = Cast<UAnimMontage>(AnimAsset))
	{
		NewEntry.InitializeAs<FPoseSearchDatabaseAnimMontage>();
		NewEntry.GetMutablePtr<FPoseSearchDatabaseAnimMontage>()->AnimMontage = Montage;
	}
	else
	{
		OutError = FString::Printf(TEXT("Animation asset type not supported by PoseSearch: %s"), *AnimAsset->GetClass()->GetName());
		return false;
	}
	Database->AnimationAssets.Add(MoveTemp(NewEntry));
	return true;
#endif
}

// ─── State Machine Helpers ────────────────────────────────────────

static UAnimBlueprint* LoadAnimBP(const FString& Path)
{
	return LoadObject<UAnimBlueprint>(nullptr, *Path);
}


static UEdGraph* FindGraphByName(UBlueprint* BP, const FString& Name)
{
	TArray<UEdGraph*> All;
	BP->GetAllGraphs(All);
	for (UEdGraph* G : All)
	{
		if (G && G->GetName() == Name) return G;
	}
	return nullptr;
}

// Find the SM container node (UAnimGraphNode_StateMachine) by its machine name


// Find the SM container node (UAnimGraphNode_StateMachine) by its machine name
static UAnimGraphNode_StateMachine* FindStateMachineNode(UBlueprint* BP, const FString& MachineName)
{
	TArray<UEdGraph*> All;
	BP->GetAllGraphs(All);
	for (UEdGraph* G : All)
	{
		for (UEdGraphNode* Node : G->Nodes)
		{
			if (UAnimGraphNode_StateMachine* SM = Cast<UAnimGraphNode_StateMachine>(Node))
			{
				if (UAnimationStateMachineGraph* SMGraph = Cast<UAnimationStateMachineGraph>(SM->EditorStateMachineGraph))
				{
					if (SMGraph->GetName() == MachineName || SM->GetNodeTitle(ENodeTitleType::FullTitle).ToString().Contains(MachineName))
					{
						return SM;
					}
				}
			}
		}
	}
	return nullptr;
}

// Find a state node by name within a state machine graph


// Find a state node by name within a state machine graph
static UAnimStateNode* FindStateNode(UAnimationStateMachineGraph* SMGraph, const FString& StateName)
{
	for (UEdGraphNode* Node : SMGraph->Nodes)
	{
		if (UAnimStateNode* State = Cast<UAnimStateNode>(Node))
		{
			if (State->GetStateName() == StateName)
			{
				return State;
			}
		}
	}
	return nullptr;
}


static void CompileAndSave(UBlueprint* BP)
{
	FKismetEditorUtilities::CompileBlueprint(BP);
	SaveAssetPackage(BP);
}

// ─── State Machine Handlers ──────────────────────────────────────


// ─── State Machine Handlers ──────────────────────────────────────

TSharedPtr<FJsonValue> FAnimationHandlers::CreateStateMachine(const TSharedPtr<FJsonObject>& Params)
{
	FString AssetPath;
	if (auto Err = RequireStringAlt(Params, TEXT("assetPath"), TEXT("path"), AssetPath)) return Err;

	FString Name = OptionalString(Params, TEXT("name"), TEXT("NewStateMachine"));
	FString GraphName = OptionalString(Params, TEXT("graphName"), TEXT("AnimGraph"));

	UAnimBlueprint* AnimBP = LoadAnimBP(AssetPath);
	if (!AnimBP)
	{
		return MCPError(FString::Printf(TEXT("AnimBlueprint not found: %s"), *AssetPath));
	}

	UEdGraph* TargetGraph = FindGraphByName(AnimBP, GraphName);
	if (!TargetGraph)
	{
		return MCPError(FString::Printf(TEXT("Graph not found: %s"), *GraphName));
	}

	// Idempotency: check for existing state machine by name
	if (FindStateMachineNode(AnimBP, Name))
	{
		auto Existed = MCPSuccess();
		MCPSetExisted(Existed);
		Existed->SetStringField(TEXT("assetPath"), AssetPath);
		Existed->SetStringField(TEXT("name"), Name);
		Existed->SetStringField(TEXT("graphName"), GraphName);
		return MCPResult(Existed);
	}

	// Create the state machine container node in the AnimGraph
	UAnimGraphNode_StateMachine* SMNode = NewObject<UAnimGraphNode_StateMachine>(TargetGraph);
	TargetGraph->AddNode(SMNode, false, false);
	SMNode->CreateNewGuid();
	SMNode->PostPlacedNewNode();  // This creates the EditorStateMachineGraph sub-graph
	SMNode->AllocateDefaultPins();
	SMNode->NodePosX = 200;
	SMNode->NodePosY = 0;

	// Rename the state machine graph to the desired name
	if (SMNode->EditorStateMachineGraph)
	{
		SMNode->EditorStateMachineGraph->Rename(*Name);
	}

	CompileAndSave(AnimBP);

	auto Result = MCPSuccess();
	MCPSetCreated(Result);
	Result->SetStringField(TEXT("assetPath"), AssetPath);
	Result->SetStringField(TEXT("name"), Name);
	Result->SetStringField(TEXT("graphName"), GraphName);
	// The machine is created empty, so removing it puts the AnimGraph back
	// exactly as it was. remove_state_machine addresses it by the same name
	// this call reports.
	TSharedPtr<FJsonObject> Rollback = MakeShared<FJsonObject>();
	Rollback->SetStringField(TEXT("assetPath"), AssetPath);
	Rollback->SetStringField(TEXT("stateMachineName"), Name);
	MCPSetRollback(Result, TEXT("remove_state_machine"), Rollback);
	Result->SetBoolField(TEXT("rollbackLossy"), false);

	return MCPResult(Result);
}


TSharedPtr<FJsonValue> FAnimationHandlers::AddState(const TSharedPtr<FJsonObject>& Params)
{
	FString AssetPath;
	if (auto Err = RequireStringAlt(Params, TEXT("assetPath"), TEXT("path"), AssetPath)) return Err;

	FString SMName;
	if (auto Err = RequireString(Params, TEXT("stateMachineName"), SMName)) return Err;

	FString StateName;
	if (auto Err = RequireString(Params, TEXT("stateName"), StateName)) return Err;

	UAnimBlueprint* AnimBP = LoadAnimBP(AssetPath);
	if (!AnimBP)
	{
		return MCPError(FString::Printf(TEXT("AnimBlueprint not found: %s"), *AssetPath));
	}

	UAnimGraphNode_StateMachine* SMNode = FindStateMachineNode(AnimBP, SMName);
	if (!SMNode)
	{
		return MCPError(FString::Printf(TEXT("State machine '%s' not found"), *SMName));
	}

	UAnimationStateMachineGraph* SMGraph = Cast<UAnimationStateMachineGraph>(SMNode->EditorStateMachineGraph);
	if (!SMGraph)
	{
		return MCPError(TEXT("State machine has no editor graph"));
	}

	// Idempotency: existing state with this name short-circuits
	const FString OnConflict = OptionalString(Params, TEXT("onConflict"), TEXT("skip"));
	if (FindStateNode(SMGraph, StateName))
	{
		if (OnConflict == TEXT("error"))
		{
			return MCPError(FString::Printf(TEXT("State '%s' already exists"), *StateName));
		}
		auto Existed = MCPSuccess();
		MCPSetExisted(Existed);
		Existed->SetStringField(TEXT("assetPath"), AssetPath);
		Existed->SetStringField(TEXT("stateMachineName"), SMName);
		Existed->SetStringField(TEXT("stateName"), StateName);
		return MCPResult(Existed);
	}

	// #446: Pre-flight - reject state names that would collide with another
	// graph in the same outer. The crash report happened when add_state's
	// Rename call landed on a name already owned by another sibling graph;
	// CompileBlueprint then walked the duplicate-named graph and asserted.
	{
		UObject* GraphOuter = SMGraph->GetOuter();
		if (GraphOuter)
		{
#if UE_MCP_HAS_5_7_API
			if (UObject* Existing = StaticFindObject(nullptr, GraphOuter, *StateName, EFindObjectFlags::None))
#else
			if (UObject* Existing = StaticFindObject(nullptr, GraphOuter, *StateName, /*ExactClass*/ false))
#endif
			{
				return MCPError(FString::Printf(
					TEXT("Cannot create state '%s' - name collides with existing %s in the state machine graph outer. Pick a unique stateName."),
					*StateName, *Existing->GetClass()->GetName()));
			}
		}
	}

	// Create state node
	UAnimStateNode* NewState = NewObject<UAnimStateNode>(SMGraph);
	SMGraph->AddNode(NewState, false, false);
	NewState->CreateNewGuid();
	NewState->PostPlacedNewNode();
	NewState->AllocateDefaultPins();

	// Set the state name via the BoundGraph (the state's internal graph).
	// #446: rename via RenameGraph so redirectors are not authored under the
	// asset, and bail with a clear error if the rename is refused instead of
	// continuing into Compile (which then crashes on the half-renamed graph).
	if (NewState->BoundGraph)
	{
		UObject* GraphOuter = NewState->BoundGraph->GetOuter();
		const FString DesiredName = StateName;
#if UE_MCP_HAS_5_7_API
		if (UObject* Collision = StaticFindObject(nullptr, GraphOuter, *DesiredName, EFindObjectFlags::None))
#else
		if (UObject* Collision = StaticFindObject(nullptr, GraphOuter, *DesiredName, /*ExactClass*/ false))
#endif
		{
			if (Collision != NewState->BoundGraph)
			{
				SMGraph->RemoveNode(NewState);
				return MCPError(FString::Printf(
					TEXT("State name collision while renaming BoundGraph to '%s' (collides with %s). Rolled back the unfinished state."),
					*DesiredName, *Collision->GetClass()->GetName()));
			}
		}
		if (!NewState->BoundGraph->Rename(*DesiredName, GraphOuter, REN_DontCreateRedirectors))
		{
			SMGraph->RemoveNode(NewState);
			return MCPError(FString::Printf(
				TEXT("Failed to rename BoundGraph to '%s' (RenameGraph returned false). Rolled back the unfinished state."),
				*DesiredName));
		}
	}

	// Position states in a grid
	int32 StateCount = 0;
	for (UEdGraphNode* N : SMGraph->Nodes) { if (Cast<UAnimStateNode>(N)) StateCount++; }
	NewState->NodePosX = 300 + ((StateCount - 1) % 4) * 300;
	NewState->NodePosY = ((StateCount - 1) / 4) * 200;

	CompileAndSave(AnimBP);

	auto Result = MCPSuccess();
	MCPSetCreated(Result);
	Result->SetStringField(TEXT("assetPath"), AssetPath);
	Result->SetStringField(TEXT("stateMachineName"), SMName);
	Result->SetStringField(TEXT("stateName"), StateName);
	// The state is created empty and this branch only runs when it did not
	// exist, so remove_state returns the machine to its prior shape.
	TSharedPtr<FJsonObject> Rollback = MakeShared<FJsonObject>();
	Rollback->SetStringField(TEXT("assetPath"), AssetPath);
	Rollback->SetStringField(TEXT("stateMachineName"), SMName);
	Rollback->SetStringField(TEXT("stateName"), StateName);
	MCPSetRollback(Result, TEXT("remove_state"), Rollback);
	Result->SetBoolField(TEXT("rollbackLossy"), false);

	return MCPResult(Result);
}


TSharedPtr<FJsonValue> FAnimationHandlers::AddTransition(const TSharedPtr<FJsonObject>& Params)
{
	FString AssetPath;
	if (auto Err = RequireStringAlt(Params, TEXT("assetPath"), TEXT("path"), AssetPath)) return Err;

	FString SMName;
	if (auto Err = RequireString(Params, TEXT("stateMachineName"), SMName)) return Err;

	FString FromState;
	if (auto Err = RequireString(Params, TEXT("fromState"), FromState)) return Err;

	FString ToState;
	if (auto Err = RequireString(Params, TEXT("toState"), ToState)) return Err;

	UAnimBlueprint* AnimBP = LoadAnimBP(AssetPath);
	if (!AnimBP)
	{
		return MCPError(FString::Printf(TEXT("AnimBlueprint not found: %s"), *AssetPath));
	}

	UAnimGraphNode_StateMachine* SMNode = FindStateMachineNode(AnimBP, SMName);
	if (!SMNode)
	{
		return MCPError(FString::Printf(TEXT("State machine '%s' not found"), *SMName));
	}

	UAnimationStateMachineGraph* SMGraph = Cast<UAnimationStateMachineGraph>(SMNode->EditorStateMachineGraph);
	UAnimStateNode* From = FindStateNode(SMGraph, FromState);
	UAnimStateNode* To = FindStateNode(SMGraph, ToState);
	if (!From)
	{
		return MCPError(FString::Printf(TEXT("State '%s' not found"), *FromState));
	}
	if (!To)
	{
		return MCPError(FString::Printf(TEXT("State '%s' not found"), *ToState));
	}

	// Idempotency: check if a transition From→To already exists
	UEdGraphPin* FromOutPin = From->GetOutputPin();
	if (FromOutPin)
	{
		for (UEdGraphPin* Linked : FromOutPin->LinkedTo)
		{
			if (!Linked || !Linked->GetOwningNode()) continue;
			UAnimStateTransitionNode* ExistingTrans = Cast<UAnimStateTransitionNode>(Linked->GetOwningNode());
			if (!ExistingTrans) continue;
			UEdGraphPin* ExistingTransOut = ExistingTrans->GetOutputPin();
			if (!ExistingTransOut) continue;
			for (UEdGraphPin* ToLinked : ExistingTransOut->LinkedTo)
			{
				if (ToLinked && ToLinked->GetOwningNode() == To)
				{
					auto ExistedRes = MCPSuccess();
					MCPSetExisted(ExistedRes);
					ExistedRes->SetStringField(TEXT("assetPath"), AssetPath);
					ExistedRes->SetStringField(TEXT("stateMachineName"), SMName);
					ExistedRes->SetStringField(TEXT("fromState"), FromState);
					ExistedRes->SetStringField(TEXT("toState"), ToState);
					return MCPResult(ExistedRes);
				}
			}
		}
	}

	// Create transition node
	UAnimStateTransitionNode* TransNode = NewObject<UAnimStateTransitionNode>(SMGraph);
	SMGraph->AddNode(TransNode, false, false);
	TransNode->CreateNewGuid();
	TransNode->PostPlacedNewNode();
	TransNode->AllocateDefaultPins();

	// Position between the two states
	TransNode->NodePosX = (From->NodePosX + To->NodePosX) / 2;
	TransNode->NodePosY = (From->NodePosY + To->NodePosY) / 2;

	// Wire: From output → Transition input, Transition output → To input
	UEdGraphPin* FromOut = From->GetOutputPin();
	UEdGraphPin* TransIn = TransNode->GetInputPin();
	UEdGraphPin* TransOut = TransNode->GetOutputPin();
	UEdGraphPin* ToIn = To->GetInputPin();

	if (FromOut && TransIn)
	{
		FromOut->MakeLinkTo(TransIn);
	}
	if (TransOut && ToIn)
	{
		TransOut->MakeLinkTo(ToIn);
	}

	CompileAndSave(AnimBP);

	auto Result = MCPSuccess();
	MCPSetCreated(Result);
	Result->SetStringField(TEXT("assetPath"), AssetPath);
	Result->SetStringField(TEXT("stateMachineName"), SMName);
	Result->SetStringField(TEXT("fromState"), FromState);
	Result->SetStringField(TEXT("toState"), ToState);
	// #630: expose the transition node's stable GUID so callers can address it
	// by handle (from/to state names are ambiguous when multiple transitions
	// share endpoints).
	Result->SetStringField(TEXT("transitionGuid"), TransNode->NodeGuid.ToString());
	// #630: expose the transition's rule graph name. The condition ("can enter
	// transition") is authored in this bound graph - address it by name with the
	// standard blueprint graph tools (add_node / connect_pins into the
	// TransitionResult node's bCanEnterTransition pin) to set any condition.
	if (TransNode->BoundGraph)
	{
		Result->SetStringField(TEXT("boundGraph"), TransNode->BoundGraph->GetName());
	}
	// remove_transition addresses a transition by transitionGuid, which is the
	// GUID this call just minted, so the rollback removes THIS transition and
	// not a sibling that happens to share the same endpoints. The transition is
	// created with an empty rule graph, so nothing authored is lost.
	TSharedPtr<FJsonObject> Rollback = MakeShared<FJsonObject>();
	Rollback->SetStringField(TEXT("assetPath"), AssetPath);
	Rollback->SetStringField(TEXT("stateMachineName"), SMName);
	Rollback->SetStringField(TEXT("transitionGuid"), TransNode->NodeGuid.ToString());
	MCPSetRollback(Result, TEXT("remove_transition"), Rollback);
	Result->SetBoolField(TEXT("rollbackLossy"), false);

	return MCPResult(Result);
}


TSharedPtr<FJsonValue> FAnimationHandlers::SetStateAnimation(const TSharedPtr<FJsonObject>& Params)
{
	FString AssetPath;
	if (auto Err = RequireStringAlt(Params, TEXT("assetPath"), TEXT("path"), AssetPath)) return Err;

	FString SMName, StateName, AnimAssetPath;
	if (!Params->TryGetStringField(TEXT("stateMachineName"), SMName) ||
		!Params->TryGetStringField(TEXT("stateName"), StateName) ||
		!Params->TryGetStringField(TEXT("animAssetPath"), AnimAssetPath))
	{
		return MCPError(TEXT("Missing required params: stateMachineName, stateName, animAssetPath"));
	}

	UAnimBlueprint* AnimBP = LoadAnimBP(AssetPath);
	if (!AnimBP)
	{
		return MCPError(FString::Printf(TEXT("AnimBlueprint not found: %s"), *AssetPath));
	}

	UAnimGraphNode_StateMachine* SMNode = FindStateMachineNode(AnimBP, SMName);
	if (!SMNode)
	{
		return MCPError(FString::Printf(TEXT("State machine '%s' not found"), *SMName));
	}

	UAnimationStateMachineGraph* SMGraph = Cast<UAnimationStateMachineGraph>(SMNode->EditorStateMachineGraph);
	UAnimStateNode* State = FindStateNode(SMGraph, StateName);
	if (!State)
	{
		return MCPError(FString::Printf(TEXT("State '%s' not found"), *StateName));
	}

	// Load the animation asset
	UAnimationAsset* AnimAsset = LoadObject<UAnimationAsset>(nullptr, *AnimAssetPath);
	if (!AnimAsset)
	{
		return MCPError(FString::Printf(TEXT("Animation asset not found: %s"), *AnimAssetPath));
	}

	// Get the state's bound graph (internal graph that plays the animation)
	UEdGraph* StateGraph = State->BoundGraph;
	if (!StateGraph)
	{
		return MCPError(TEXT("State has no BoundGraph"));
	}

	// Find or create the appropriate player node inside the state graph
	// Look for existing sequence player or blendspace player
	UAnimGraphNode_SequencePlayer* SeqPlayer = nullptr;
	UAnimGraphNode_BlendSpacePlayer* BSPlayer = nullptr;
	for (UEdGraphNode* Node : StateGraph->Nodes)
	{
		if (!SeqPlayer) SeqPlayer = Cast<UAnimGraphNode_SequencePlayer>(Node);
		if (!BSPlayer) BSPlayer = Cast<UAnimGraphNode_BlendSpacePlayer>(Node);
	}

	// Capture what the state plays right now. The write lands on the player node
	// matching the NEW asset's type, which is not necessarily the node that held
	// the old one: a state driven by a BlendSpacePlayer and then written with a
	// sequence gets a second, freshly created SequencePlayer. So look at the node
	// this write targets first, and fall back to the other one, which is what
	// keeps the "no player node" branch from claiming an empty state that was not.
	const bool bWritesSequencePlayer = AnimAsset->IsA<UAnimSequence>();
	UAnimGraphNode_Base* TargetPlayer = bWritesSequencePlayer
		? static_cast<UAnimGraphNode_Base*>(SeqPlayer) : static_cast<UAnimGraphNode_Base*>(BSPlayer);
	UAnimGraphNode_Base* OtherPlayer = bWritesSequencePlayer
		? static_cast<UAnimGraphNode_Base*>(BSPlayer) : static_cast<UAnimGraphNode_Base*>(SeqPlayer);

	FString PrevAnimAssetPath;
	bool bPrevOnOtherPlayer = false;
	if (TargetPlayer)
	{
		if (UAnimationAsset* Prev = TargetPlayer->GetAnimationAsset()) PrevAnimAssetPath = Prev->GetPathName();
	}
	if (PrevAnimAssetPath.IsEmpty() && OtherPlayer)
	{
		if (UAnimationAsset* Prev = OtherPlayer->GetAnimationAsset())
		{
			PrevAnimAssetPath = Prev->GetPathName();
			bPrevOnOtherPlayer = true;
		}
	}
	const bool bUnchanged = !bPrevOnOtherPlayer
		&& !PrevAnimAssetPath.IsEmpty()
		&& PrevAnimAssetPath == AnimAsset->GetPathName();

	if (UAnimSequence* Seq = Cast<UAnimSequence>(AnimAsset))
	{
		if (!SeqPlayer)
		{
			SeqPlayer = NewObject<UAnimGraphNode_SequencePlayer>(StateGraph);
			StateGraph->AddNode(SeqPlayer, false, false);
			SeqPlayer->CreateNewGuid();
			SeqPlayer->PostPlacedNewNode();
			SeqPlayer->AllocateDefaultPins();
		}
		SeqPlayer->SetAnimationAsset(Seq);
	}
	else if (UBlendSpace* BS = Cast<UBlendSpace>(AnimAsset))
	{
		if (!BSPlayer)
		{
			BSPlayer = NewObject<UAnimGraphNode_BlendSpacePlayer>(StateGraph);
			StateGraph->AddNode(BSPlayer, false, false);
			BSPlayer->CreateNewGuid();
			BSPlayer->PostPlacedNewNode();
			BSPlayer->AllocateDefaultPins();
		}
		BSPlayer->SetAnimationAsset(BS);
	}
	else
	{
		return MCPError(FString::Printf(TEXT("Unsupported animation asset type: %s"), *AnimAsset->GetClass()->GetName()));
	}

	CompileAndSave(AnimBP);

	auto Result = MCPSuccess();
	MCPSetUpdated(Result);
	Result->SetBoolField(TEXT("unchanged"), bUnchanged);
	Result->SetStringField(TEXT("assetPath"), AssetPath);
	Result->SetStringField(TEXT("stateName"), StateName);
	Result->SetStringField(TEXT("animAssetPath"), AnimAssetPath);
	Result->SetStringField(TEXT("previousAnimAssetPath"), PrevAnimAssetPath);
	Result->SetBoolField(TEXT("previousOnOtherPlayerNode"), bPrevOnOtherPlayer);

	if (!PrevAnimAssetPath.IsEmpty())
	{
		TSharedPtr<FJsonObject> Rollback = MakeShared<FJsonObject>();
		Rollback->SetStringField(TEXT("assetPath"), AssetPath);
		Rollback->SetStringField(TEXT("stateMachineName"), SMName);
		Rollback->SetStringField(TEXT("stateName"), StateName);
		Rollback->SetStringField(TEXT("animAssetPath"), PrevAnimAssetPath);
		MCPSetRollback(Result, TEXT("set_state_animation"), Rollback);
		Result->SetBoolField(TEXT("rollbackLossy"), bPrevOnOtherPlayer);
		if (bPrevOnOtherPlayer)
		{
			Result->SetStringField(TEXT("rollbackNote"),
				TEXT("The animation this state played lived on a player node of the other kind, so this call created a second player node rather than overwriting it. ")
				TEXT("The replay points the state back at the previous asset on its original node, but the node this call added stays in the state's inner graph and no action deletes it."));
		}
	}
	else
	{
		Result->SetBoolField(TEXT("rollbackPossible"), false);
		Result->SetStringField(TEXT("rollbackNote"),
			TEXT("No player node in this state held an animation before the call, so this write created the node or filled an empty one. ")
			TEXT("set_state_animation requires an animAssetPath and cannot clear one, and no action deletes a player node from a state's inner graph."));
	}

	return MCPResult(Result);
}


TSharedPtr<FJsonValue> FAnimationHandlers::SetTransitionBlend(const TSharedPtr<FJsonObject>& Params)
{
	FString AssetPath;
	if (auto Err = RequireStringAlt(Params, TEXT("assetPath"), TEXT("path"), AssetPath)) return Err;

	FString SMName, FromState, ToState;
	if (!Params->TryGetStringField(TEXT("stateMachineName"), SMName) ||
		!Params->TryGetStringField(TEXT("fromState"), FromState) ||
		!Params->TryGetStringField(TEXT("toState"), ToState))
	{
		return MCPError(TEXT("Missing required params: stateMachineName, fromState, toState"));
	}

	UAnimBlueprint* AnimBP = LoadAnimBP(AssetPath);
	if (!AnimBP)
	{
		return MCPError(FString::Printf(TEXT("AnimBlueprint not found: %s"), *AssetPath));
	}

	UAnimGraphNode_StateMachine* SMNode = FindStateMachineNode(AnimBP, SMName);
	if (!SMNode)
	{
		return MCPError(FString::Printf(TEXT("State machine '%s' not found"), *SMName));
	}

	UAnimationStateMachineGraph* SMGraph = Cast<UAnimationStateMachineGraph>(SMNode->EditorStateMachineGraph);

	// Find the transition between fromState and toState
	UAnimStateTransitionNode* TransNode = nullptr;
	for (UEdGraphNode* Node : SMGraph->Nodes)
	{
		if (UAnimStateTransitionNode* T = Cast<UAnimStateTransitionNode>(Node))
		{
			UAnimStateNode* Prev = Cast<UAnimStateNode>(T->GetPreviousState());
			UAnimStateNode* Next = Cast<UAnimStateNode>(T->GetNextState());
			if (Prev && Next && Prev->GetStateName() == FromState && Next->GetStateName() == ToState)
			{
				TransNode = T;
				break;
			}
		}
	}

	if (!TransNode)
	{
		return MCPError(FString::Printf(TEXT("No transition from '%s' to '%s'"), *FromState, *ToState));
	}

	// Capture what the transition held, so the write can name the call that puts
	// it back. Both fields are read before either is touched.
	const float PrevCrossfade = TransNode->CrossfadeDuration;
	const ETransitionLogicType::Type PrevLogicType = TransNode->LogicType;
	const EAlphaBlendOption PrevBlendMode = TransNode->BlendMode;

	// Set blend duration
	double BlendDuration = 0.2;
	const bool bWroteDuration = Params->TryGetNumberField(TEXT("blendDuration"), BlendDuration);
	if (bWroteDuration)
	{
		TransNode->CrossfadeDuration = static_cast<float>(BlendDuration);
	}

	// Set blend logic (Standard vs Inertialization)
	FString BlendLogic;
	const bool bWroteLogic = Params->TryGetStringField(TEXT("blendLogic"), BlendLogic);
	if (bWroteLogic)
	{
		if (BlendLogic.Equals(TEXT("Inertialization"), ESearchCase::IgnoreCase))
		{
			TransNode->BlendMode = EAlphaBlendOption::Linear;
			TransNode->LogicType = ETransitionLogicType::TLT_Inertialization;
		}
		else // Standard
		{
			TransNode->LogicType = ETransitionLogicType::TLT_StandardBlend;
		}
	}

	const bool bChanged =
		(bWroteDuration && !FMath::IsNearlyEqual(PrevCrossfade, TransNode->CrossfadeDuration))
		|| (bWroteLogic && (PrevLogicType != TransNode->LogicType || PrevBlendMode != TransNode->BlendMode));

	CompileAndSave(AnimBP);

	auto Result = MCPSuccess();
	MCPSetUpdated(Result);
	Result->SetBoolField(TEXT("unchanged"), !bChanged);
	Result->SetStringField(TEXT("assetPath"), AssetPath);
	Result->SetStringField(TEXT("fromState"), FromState);
	Result->SetStringField(TEXT("toState"), ToState);
	Result->SetNumberField(TEXT("blendDuration"), BlendDuration);
	Result->SetNumberField(TEXT("previousBlendDuration"), PrevCrossfade);

	// Replay through this same action with the captured values. blendLogic is
	// only carried when the previous logic was one of the two this action can
	// author: a transition that used a Custom blend graph is named in the note
	// rather than silently downgraded to Standard by the rollback.
	const bool bPrevLogicExpressible = PrevLogicType != ETransitionLogicType::TLT_Custom;
	Result->SetStringField(TEXT("previousBlendLogic"),
		PrevLogicType == ETransitionLogicType::TLT_Inertialization ? TEXT("Inertialization")
		: (PrevLogicType == ETransitionLogicType::TLT_Custom ? TEXT("Custom") : TEXT("Standard")));

	TSharedPtr<FJsonObject> Rollback = MakeShared<FJsonObject>();
	Rollback->SetStringField(TEXT("assetPath"), AssetPath);
	Rollback->SetStringField(TEXT("stateMachineName"), SMName);
	Rollback->SetStringField(TEXT("fromState"), FromState);
	Rollback->SetStringField(TEXT("toState"), ToState);
	Rollback->SetNumberField(TEXT("blendDuration"), PrevCrossfade);
	if (bWroteLogic && bPrevLogicExpressible)
	{
		Rollback->SetStringField(TEXT("blendLogic"),
			PrevLogicType == ETransitionLogicType::TLT_Inertialization ? TEXT("Inertialization") : TEXT("Standard"));
	}
	MCPSetRollback(Result, TEXT("set_transition_blend"), Rollback);

	// Two things the replay cannot put back. Writing "Inertialization" also
	// stomps BlendMode to Linear, and this action has no parameter for it; and
	// a Custom blend graph is not one of the two logic types it can author.
	const bool bLossy = bWroteLogic && (!bPrevLogicExpressible || PrevBlendMode != TransNode->BlendMode);
	Result->SetBoolField(TEXT("rollbackLossy"), bLossy);
	if (bLossy)
	{
		Result->SetStringField(TEXT("rollbackNote"), !bPrevLogicExpressible
			? TEXT("The transition used a Custom blend graph, which set_transition_blend cannot author, so the rollback restores only blendDuration and leaves the logic type as this call set it.")
			: TEXT("Selecting Inertialization also sets the transition's BlendMode to Linear, and set_transition_blend has no parameter for BlendMode, so the previous blend curve is not restored."));
	}

	return MCPResult(Result);
}


// Locate a transition node in a state machine graph by transitionGuid (preferred,
// unambiguous) or by fromState+toState endpoints. Returns nullptr if none match.
static UAnimStateTransitionNode* FindTransitionNode(
	UAnimationStateMachineGraph* SMGraph,
	const FString& TransitionGuid,
	const FString& FromState,
	const FString& ToState)
{
	for (UEdGraphNode* Node : SMGraph->Nodes)
	{
		UAnimStateTransitionNode* T = Cast<UAnimStateTransitionNode>(Node);
		if (!T) continue;

		if (!TransitionGuid.IsEmpty())
		{
			if (T->NodeGuid.ToString() == TransitionGuid) return T;
			continue;
		}

		UAnimStateNode* Prev = Cast<UAnimStateNode>(T->GetPreviousState());
		UAnimStateNode* Next = Cast<UAnimStateNode>(T->GetNextState());
		if (Prev && Next && Prev->GetStateName() == FromState && Next->GetStateName() == ToState)
		{
			return T;
		}
	}
	return nullptr;
}

// #707: author a transition's "can enter transition" condition from a bool
// variable. Every transition's rule graph is named "Transition", so the generic
// blueprint(read_graph/add_node/connect_pins) tools (which address by graphName)
// can only ever reach the first one. This native setter addresses the transition
// by transitionGuid or fromState+toState, then wires a bool VariableGet (optionally
// negated) into the TransitionResult node's bCanEnterTransition pin.
TSharedPtr<FJsonValue> FAnimationHandlers::SetTransitionCondition(const TSharedPtr<FJsonObject>& Params)
{
	FString AssetPath;
	if (auto Err = RequireStringAlt(Params, TEXT("assetPath"), TEXT("path"), AssetPath)) return Err;

	FString SMName;
	if (auto Err = RequireString(Params, TEXT("stateMachineName"), SMName)) return Err;

	FString VariableName;
	if (auto Err = RequireString(Params, TEXT("variableName"), VariableName)) return Err;

	// Selector: transitionGuid is unambiguous; fromState+toState is the fallback.
	const FString TransitionGuid = OptionalString(Params, TEXT("transitionGuid"), TEXT(""));
	const FString FromState = OptionalString(Params, TEXT("fromState"), TEXT(""));
	const FString ToState = OptionalString(Params, TEXT("toState"), TEXT(""));
	if (TransitionGuid.IsEmpty() && (FromState.IsEmpty() || ToState.IsEmpty()))
	{
		return MCPError(TEXT("Provide transitionGuid, or both fromState and toState, to identify the transition."));
	}

	const bool bNegate = OptionalBool(Params, TEXT("negate"), false);

	UAnimBlueprint* AnimBP = LoadAnimBP(AssetPath);
	if (!AnimBP)
	{
		return MCPError(FString::Printf(TEXT("AnimBlueprint not found: %s"), *AssetPath));
	}

	UAnimGraphNode_StateMachine* SMNode = FindStateMachineNode(AnimBP, SMName);
	if (!SMNode)
	{
		return MCPError(FString::Printf(TEXT("State machine '%s' not found"), *SMName));
	}

	UAnimationStateMachineGraph* SMGraph = Cast<UAnimationStateMachineGraph>(SMNode->EditorStateMachineGraph);
	if (!SMGraph)
	{
		return MCPError(TEXT("State machine has no editor graph"));
	}

	UAnimStateTransitionNode* TransNode = FindTransitionNode(SMGraph, TransitionGuid, FromState, ToState);
	if (!TransNode)
	{
		if (!TransitionGuid.IsEmpty())
			return MCPError(FString::Printf(TEXT("No transition with transitionGuid '%s'"), *TransitionGuid));
		return MCPError(FString::Printf(TEXT("No transition from '%s' to '%s'"), *FromState, *ToState));
	}

	// Validate the variable exists on the AnimBP and is a bool.
	UClass* VarOwnerClass = AnimBP->SkeletonGeneratedClass ? AnimBP->SkeletonGeneratedClass : AnimBP->GeneratedClass;
	if (VarOwnerClass && !CastField<FBoolProperty>(VarOwnerClass->FindPropertyByName(FName(*VariableName))))
	{
		return MCPError(FString::Printf(
			TEXT("Variable '%s' not found on the AnimBlueprint or is not a bool. Create a bool variable first (blueprint(add_variable))."),
			*VariableName));
	}

	UAnimationTransitionGraph* TransGraph = Cast<UAnimationTransitionGraph>(TransNode->BoundGraph);
	if (!TransGraph)
	{
		return MCPError(TEXT("Transition has no rule graph (BoundGraph)."));
	}
	UAnimGraphNode_TransitionResult* ResultNode = TransGraph->GetResultNode();
	if (!ResultNode)
	{
		return MCPError(TEXT("Transition rule graph has no result node."));
	}

	// The result node exposes the bool condition as an input data pin
	// ("bCanEnterTransition"). Fall back to the first bool input pin.
	UEdGraphPin* ResultPin = nullptr;
	for (UEdGraphPin* Pin : ResultNode->Pins)
	{
		if (!Pin || Pin->Direction != EGPD_Input) continue;
		if (Pin->PinName == TEXT("bCanEnterTransition")) { ResultPin = Pin; break; }
		if (!ResultPin && Pin->PinType.PinCategory == UEdGraphSchema_K2::PC_Boolean) ResultPin = Pin;
	}
	if (!ResultPin)
	{
		return MCPError(TEXT("Could not find the bCanEnterTransition pin on the transition result node."));
	}

	// Capture the condition this call is about to replace, so the write can name
	// the call that puts it back. A rollback can only name what this action can
	// author: one bool VariableGet, optionally through a NOT. The node count has
	// to match too, because a graph carrying anything else holds a condition
	// this action cannot express and the wipe below destroys it.
	FString PrevVariableName;
	bool bPrevNegate = false;
	int32 PrevAuthoredNodeCount = 0;
	for (UEdGraphNode* Node : TransGraph->Nodes)
	{
		if (Node && Node != ResultNode) ++PrevAuthoredNodeCount;
	}
	if (ResultPin->LinkedTo.Num() == 1 && ResultPin->LinkedTo[0])
	{
		UEdGraphNode* SourceNode = ResultPin->LinkedTo[0]->GetOwningNode();
		if (UK2Node_VariableGet* PrevGet = Cast<UK2Node_VariableGet>(SourceNode))
		{
			PrevVariableName = PrevGet->VariableReference.GetMemberName().ToString();
		}
		else if (UK2Node_CallFunction* PrevNot = Cast<UK2Node_CallFunction>(SourceNode))
		{
			if (PrevNot->FunctionReference.GetMemberName() == FName(TEXT("Not_PreBool")))
			{
				for (UEdGraphPin* Pin : PrevNot->Pins)
				{
					if (!Pin || Pin->Direction != EGPD_Input || Pin->LinkedTo.Num() != 1 || !Pin->LinkedTo[0]) continue;
					if (UK2Node_VariableGet* NegatedGet = Cast<UK2Node_VariableGet>(Pin->LinkedTo[0]->GetOwningNode()))
					{
						PrevVariableName = NegatedGet->VariableReference.GetMemberName().ToString();
						bPrevNegate = true;
						break;
					}
				}
			}
		}
	}
	const bool bPrevExpressible = !PrevVariableName.IsEmpty()
		&& PrevAuthoredNodeCount == (bPrevNegate ? 2 : 1);

	// Idempotency: wipe any previously authored condition nodes so re-running
	// replaces the condition instead of stacking orphan nodes. The default rule
	// graph contains only the result node; anything else was authored by us.
	{
		TArray<UEdGraphNode*> ToRemove;
		for (UEdGraphNode* Node : TransGraph->Nodes)
		{
			if (Node && Node != ResultNode) ToRemove.Add(Node);
		}
		for (UEdGraphNode* Node : ToRemove)
		{
			TransGraph->RemoveNode(Node);
		}
	}
	ResultPin->BreakAllPinLinks();

	// Author: VariableGet(bool) -> [optional NOT] -> bCanEnterTransition.
	UK2Node_VariableGet* GetNode = NewObject<UK2Node_VariableGet>(TransGraph);
	TransGraph->AddNode(GetNode, false, false);
	GetNode->VariableReference.SetSelfMember(FName(*VariableName));
	GetNode->CreateNewGuid();
	GetNode->PostPlacedNewNode();
	GetNode->AllocateDefaultPins();
	GetNode->NodePosX = ResultNode->NodePosX - 400;
	GetNode->NodePosY = ResultNode->NodePosY;

	UEdGraphPin* VarOutPin = nullptr;
	for (UEdGraphPin* Pin : GetNode->Pins)
	{
		if (Pin && Pin->Direction == EGPD_Output && Pin->PinType.PinCategory == UEdGraphSchema_K2::PC_Boolean)
		{
			VarOutPin = Pin;
			break;
		}
	}
	if (!VarOutPin)
	{
		TransGraph->RemoveNode(GetNode);
		return MCPError(FString::Printf(
			TEXT("VariableGet for '%s' produced no bool output pin - is the variable a bool?"), *VariableName));
	}

	UEdGraphPin* SourcePin = VarOutPin;
	if (bNegate)
	{
		UFunction* NotFunc = UKismetMathLibrary::StaticClass()->FindFunctionByName(FName(TEXT("Not_PreBool")));
		if (NotFunc)
		{
			UK2Node_CallFunction* NotNode = NewObject<UK2Node_CallFunction>(TransGraph);
			TransGraph->AddNode(NotNode, false, false);
			NotNode->SetFromFunction(NotFunc);
			NotNode->CreateNewGuid();
			NotNode->PostPlacedNewNode();
			NotNode->AllocateDefaultPins();
			NotNode->NodePosX = ResultNode->NodePosX - 200;
			NotNode->NodePosY = ResultNode->NodePosY;

			UEdGraphPin* NotIn = nullptr;
			UEdGraphPin* NotOut = nullptr;
			for (UEdGraphPin* Pin : NotNode->Pins)
			{
				if (!Pin || Pin->PinType.PinCategory != UEdGraphSchema_K2::PC_Boolean) continue;
				if (Pin->Direction == EGPD_Input) NotIn = Pin;
				else if (Pin->Direction == EGPD_Output) NotOut = Pin;
			}
			if (NotIn && NotOut)
			{
				VarOutPin->MakeLinkTo(NotIn);
				SourcePin = NotOut;
			}
		}
	}

	SourcePin->MakeLinkTo(ResultPin);
	TransGraph->NotifyGraphChanged();

	CompileAndSave(AnimBP);

	auto Result = MCPSuccess();
	MCPSetUpdated(Result);
	Result->SetStringField(TEXT("assetPath"), AssetPath);
	Result->SetStringField(TEXT("stateMachineName"), SMName);
	Result->SetStringField(TEXT("transitionGuid"), TransNode->NodeGuid.ToString());
	if (UAnimStateNode* Prev = Cast<UAnimStateNode>(TransNode->GetPreviousState()))
		Result->SetStringField(TEXT("fromState"), Prev->GetStateName());
	if (UAnimStateNode* Next = Cast<UAnimStateNode>(TransNode->GetNextState()))
		Result->SetStringField(TEXT("toState"), Next->GetStateName());
	Result->SetStringField(TEXT("variableName"), VariableName);
	Result->SetBoolField(TEXT("negate"), bNegate);
	Result->SetStringField(TEXT("previousVariableName"), PrevVariableName);

	if (bPrevExpressible)
	{
		TSharedPtr<FJsonObject> Rollback = MakeShared<FJsonObject>();
		Rollback->SetStringField(TEXT("assetPath"), AssetPath);
		Rollback->SetStringField(TEXT("stateMachineName"), SMName);
		Rollback->SetStringField(TEXT("transitionGuid"), TransNode->NodeGuid.ToString());
		Rollback->SetStringField(TEXT("variableName"), PrevVariableName);
		Rollback->SetBoolField(TEXT("negate"), bPrevNegate);
		MCPSetRollback(Result, TEXT("set_transition_condition"), Rollback);
		Result->SetBoolField(TEXT("rollbackLossy"), false);
	}
	else
	{
		Result->SetBoolField(TEXT("rollbackPossible"), false);
		Result->SetStringField(TEXT("rollbackNote"), PrevAuthoredNodeCount == 0
			? TEXT("The transition carried no condition before this call: its rule graph held only the result node. set_transition_condition always writes a condition and has no form that clears one, so there is no inverse call.")
			: TEXT("The transition's rule graph held a condition this action cannot express (it authors one bool variable, optionally negated) and the nodes were deleted. Rebuild it with the blueprint graph tools against the rule graph of the transition named by transitionGuid."));
	}
	return MCPResult(Result);
}


TSharedPtr<FJsonValue> FAnimationHandlers::ReadStateMachine(const TSharedPtr<FJsonObject>& Params)
{
	FString AssetPath;
	if (auto Err = RequireStringAlt(Params, TEXT("assetPath"), TEXT("path"), AssetPath)) return Err;

	FString SMName;
	if (auto Err = RequireString(Params, TEXT("stateMachineName"), SMName)) return Err;

	UAnimBlueprint* AnimBP = LoadAnimBP(AssetPath);
	if (!AnimBP)
	{
		return MCPError(FString::Printf(TEXT("AnimBlueprint not found: %s"), *AssetPath));
	}

	UAnimGraphNode_StateMachine* SMNode = FindStateMachineNode(AnimBP, SMName);
	if (!SMNode)
	{
		return MCPError(FString::Printf(TEXT("State machine '%s' not found"), *SMName));
	}

	UAnimationStateMachineGraph* SMGraph = Cast<UAnimationStateMachineGraph>(SMNode->EditorStateMachineGraph);
	if (!SMGraph)
	{
		return MCPError(TEXT("State machine has no editor graph"));
	}

	auto Result = MCPSuccess();

	// Enumerate states
	TArray<TSharedPtr<FJsonValue>> StatesArray;
	for (UEdGraphNode* Node : SMGraph->Nodes)
	{
		if (UAnimStateNode* State = Cast<UAnimStateNode>(Node))
		{
			TSharedPtr<FJsonObject> StateObj = MakeShared<FJsonObject>();
			StateObj->SetStringField(TEXT("name"), State->GetStateName());

			// Check for animation asset inside the state
			if (State->BoundGraph)
			{
				for (UEdGraphNode* Inner : State->BoundGraph->Nodes)
				{
					if (UAnimGraphNode_AssetPlayerBase* AssetNode = Cast<UAnimGraphNode_AssetPlayerBase>(Inner))
					{
						if (UAnimationAsset* Asset = AssetNode->GetAnimationAsset())
						{
							StateObj->SetStringField(TEXT("animAsset"), Asset->GetPathName());
						}
					}
				}
			}

			StatesArray.Add(MakeShared<FJsonValueObject>(StateObj));
		}
	}
	Result->SetArrayField(TEXT("states"), StatesArray);

	// Enumerate transitions
	TArray<TSharedPtr<FJsonValue>> TransArray;
	for (UEdGraphNode* Node : SMGraph->Nodes)
	{
		if (UAnimStateTransitionNode* T = Cast<UAnimStateTransitionNode>(Node))
		{
			TSharedPtr<FJsonObject> TransObj = MakeShared<FJsonObject>();

			UAnimStateNode* Prev = Cast<UAnimStateNode>(T->GetPreviousState());
			UAnimStateNode* Next = Cast<UAnimStateNode>(T->GetNextState());
			if (Prev) TransObj->SetStringField(TEXT("fromState"), Prev->GetStateName());
			if (Next) TransObj->SetStringField(TEXT("toState"), Next->GetStateName());
			// #630: stable GUID handle + rule graph name. Author the condition in
			// boundGraph via the standard graph tools (into the TransitionResult
			// node's bCanEnterTransition pin).
			TransObj->SetStringField(TEXT("transitionGuid"), T->NodeGuid.ToString());
			if (T->BoundGraph) TransObj->SetStringField(TEXT("boundGraph"), T->BoundGraph->GetName());

			TransObj->SetNumberField(TEXT("blendDuration"), T->CrossfadeDuration);
			TransObj->SetStringField(TEXT("logicType"),
				T->LogicType == ETransitionLogicType::TLT_Inertialization ? TEXT("Inertialization") : TEXT("Standard"));

			TransArray.Add(MakeShared<FJsonValueObject>(TransObj));
		}
	}
	Result->SetArrayField(TEXT("transitions"), TransArray);

	Result->SetStringField(TEXT("assetPath"), AssetPath);
	Result->SetStringField(TEXT("stateMachineName"), SMName);

	return MCPResult(Result);
}

// ─── #23 / #91  read_anim_graph ─────────────────────────────────────


// ─── #23 / #91  read_anim_graph ─────────────────────────────────────

TSharedPtr<FJsonValue> FAnimationHandlers::ReadAnimGraph(const TSharedPtr<FJsonObject>& Params)
{
	FString AssetPath;
	if (auto Err = RequireStringAlt(Params, TEXT("assetPath"), TEXT("path"), AssetPath)) return Err;

	FString GraphName = OptionalString(Params, TEXT("graphName"), TEXT("AnimGraph"));

	UAnimBlueprint* AnimBP = LoadAnimBP(AssetPath);
	if (!AnimBP)
	{
		return MCPError(FString::Printf(TEXT("AnimBlueprint not found: %s"), *AssetPath));
	}

	UEdGraph* TargetGraph = FindGraphByName(AnimBP, GraphName);
	if (!TargetGraph)
	{
		return MCPError(FString::Printf(TEXT("Graph not found: %s"), *GraphName));
	}

	auto Result = MCPSuccess();

	TArray<TSharedPtr<FJsonValue>> NodesArray;
	for (UEdGraphNode* Node : TargetGraph->Nodes)
	{
		if (!Node) continue;

		TSharedPtr<FJsonObject> NodeObj = MakeShared<FJsonObject>();
		NodeObj->SetStringField(TEXT("id"), Node->NodeGuid.ToString());
		NodeObj->SetStringField(TEXT("class"), Node->GetClass()->GetName());
		NodeObj->SetStringField(TEXT("title"), Node->GetNodeTitle(ENodeTitleType::FullTitle).ToString());
		NodeObj->SetNumberField(TEXT("posX"), Node->NodePosX);
		NodeObj->SetNumberField(TEXT("posY"), Node->NodePosY);
		NodeObj->SetStringField(TEXT("comment"), Node->NodeComment);

		// ── Serialize editable properties via reflection ──
		TArray<TSharedPtr<FJsonValue>> PropsArray;
		UClass* NodeClass = Node->GetClass();
		for (TFieldIterator<FProperty> PropIt(NodeClass, EFieldIteratorFlags::IncludeSuper); PropIt; ++PropIt)
		{
			FProperty* Prop = *PropIt;
			if (!Prop || !Prop->HasAnyPropertyFlags(CPF_Edit)) continue;

			// Skip internal / noise properties
			FString PropName = Prop->GetName();
			if (PropName.StartsWith(TEXT("Node")) || PropName == TEXT("ErrorMsg") || PropName == TEXT("bHasCompilerMessage"))
				continue;

			TSharedPtr<FJsonObject> PropObj = MakeShared<FJsonObject>();
			PropObj->SetStringField(TEXT("name"), PropName);
			PropObj->SetStringField(TEXT("type"), Prop->GetCPPType());

			// Attempt to export the value to a human-readable string
			FString ValueStr;
			const void* Container = Node;
			Prop->ExportTextItem_Direct(ValueStr, Prop->ContainerPtrToValuePtr<void>(Container), nullptr, nullptr, PPF_None);
			PropObj->SetStringField(TEXT("value"), ValueStr);

			PropsArray.Add(MakeShared<FJsonValueObject>(PropObj));
		}
		NodeObj->SetArrayField(TEXT("properties"), PropsArray);

		// ── Pins ──
		TArray<TSharedPtr<FJsonValue>> PinsArray;
		for (UEdGraphPin* Pin : Node->Pins)
		{
			if (!Pin) continue;
			TSharedPtr<FJsonObject> PinObj = MakeShared<FJsonObject>();
			PinObj->SetStringField(TEXT("name"), Pin->PinName.ToString());
			PinObj->SetStringField(TEXT("type"), Pin->PinType.PinCategory.ToString());
			PinObj->SetStringField(TEXT("direction"), Pin->Direction == EGPD_Input ? TEXT("Input") : TEXT("Output"));
			PinObj->SetStringField(TEXT("defaultValue"), Pin->DefaultValue);
			PinObj->SetBoolField(TEXT("connected"), Pin->LinkedTo.Num() > 0);
			PinsArray.Add(MakeShared<FJsonValueObject>(PinObj));
		}
		NodeObj->SetArrayField(TEXT("pins"), PinsArray);

		NodesArray.Add(MakeShared<FJsonValueObject>(NodeObj));
	}

	Result->SetStringField(TEXT("assetPath"), AssetPath);
	Result->SetStringField(TEXT("graphName"), GraphName);
	Result->SetArrayField(TEXT("nodes"), NodesArray);
	Result->SetNumberField(TEXT("nodeCount"), NodesArray.Num());

	return MCPResult(Result);
}

// ─── #79 / #24  add_curve ───────────────────────────────────────────


// ─── #93  create_ik_rig ─────────────────────────────────────────────

TSharedPtr<FJsonValue> FAnimationHandlers::CreateIKRig(const TSharedPtr<FJsonObject>& Params)
{
	FString Name;
	if (auto Err = RequireString(Params, TEXT("name"), Name)) return Err;

	FString SkeletalMeshPath;
	if (auto Err = RequireString(Params, TEXT("skeletalMeshPath"), SkeletalMeshPath)) return Err;

	FString PackagePath = OptionalString(Params, TEXT("packagePath"), TEXT("/Game"));
	const FString OnConflict = OptionalString(Params, TEXT("onConflict"), TEXT("skip"));

	// Load the skeletal mesh to get the skeleton
	USkeletalMesh* SkelMesh = LoadObject<USkeletalMesh>(nullptr, *SkeletalMeshPath);
	if (!SkelMesh)
	{
		return MCPError(FString::Printf(TEXT("Failed to load SkeletalMesh at '%s'"), *SkeletalMeshPath));
	}

	UFactory* Factory = NewObject<UFactory>(GetTransientPackage(), FindObject<UClass>(nullptr, TEXT("/Script/IKRigEditor.IKRigDefinitionFactory")));
	if (!Factory)
	{
		return MCPError(TEXT("IKRigDefinitionFactory not found - is the IKRig plugin enabled?"));
	}

	auto Created = MCPCreateAssetIdempotent<UIKRigDefinition>(Name, PackagePath, OnConflict, TEXT("IKRigDefinition"), Factory);
	if (Created.EarlyReturn) return Created.EarlyReturn;
	UIKRigDefinition* IKRig = Created.Asset;

	// Prefer IKRigController to atomically configure the rig (#97, #103)
	int32 ChainsAdded = 0;
	TArray<FString> ChainErrors;
	FString RetargetRoot;
	Params->TryGetStringField(TEXT("retargetRoot"), RetargetRoot);

	if (UIKRigController* Controller = UIKRigController::GetController(IKRig))
	{
		Controller->SetSkeletalMesh(SkelMesh);

		if (!RetargetRoot.IsEmpty())
		{
			Controller->SetRetargetRoot(FName(*RetargetRoot));
		}

		const TArray<TSharedPtr<FJsonValue>>* ChainsArr = nullptr;
		if (Params->TryGetArrayField(TEXT("chains"), ChainsArr))
		{
			for (const TSharedPtr<FJsonValue>& V : *ChainsArr)
			{
				if (!V.IsValid() || V->Type != EJson::Object) continue;
				auto O = V->AsObject();
				FString CName = O->GetStringField(TEXT("name"));
				FString SBone = O->GetStringField(TEXT("startBone"));
				FString EBone = O->GetStringField(TEXT("endBone"));
				FString Goal;
				O->TryGetStringField(TEXT("goal"), Goal);
				if (CName.IsEmpty() || SBone.IsEmpty() || EBone.IsEmpty())
				{
					ChainErrors.Add(FString::Printf(TEXT("Chain missing name/startBone/endBone: %s"), *CName));
					continue;
				}
				FName AddedName = Controller->AddRetargetChain(
					FName(*CName),
					FName(*SBone),
					FName(*EBone),
					Goal.IsEmpty() ? NAME_None : FName(*Goal));
				if (AddedName != NAME_None)
				{
					ChainsAdded++;
				}
				else
				{
					ChainErrors.Add(FString::Printf(TEXT("Failed to add chain: %s"), *CName));
				}
			}
		}
	}
	else
	{
		// Fallback if controller not available: basic preview mesh only
		IKRig->SetPreviewMesh(SkelMesh, false);
	}

	IKRig->MarkPackageDirty();
	UEditorAssetLibrary::SaveAsset(IKRig->GetPathName());

	auto Result = MCPSuccess();
	MCPSetCreated(Result);
	Result->SetStringField(TEXT("assetPath"), IKRig->GetPathName());
	Result->SetStringField(TEXT("name"), Name);
	Result->SetStringField(TEXT("skeletalMeshPath"), SkeletalMeshPath);
	if (!RetargetRoot.IsEmpty()) Result->SetStringField(TEXT("retargetRoot"), RetargetRoot);
	Result->SetNumberField(TEXT("chainsAdded"), ChainsAdded);
	if (ChainErrors.Num() > 0)
	{
		TArray<TSharedPtr<FJsonValue>> ErrArr;
		for (const FString& E : ChainErrors) ErrArr.Add(MakeShared<FJsonValueString>(E));
		Result->SetArrayField(TEXT("chainErrors"), ErrArr);
	}
	MCPSetDeleteAssetRollback(Result, IKRig->GetPathName());

	return MCPResult(Result);
}

// ─── #93  read_ik_rig ───────────────────────────────────────────────


// ─── #93  read_ik_rig ───────────────────────────────────────────────

// The IK rig's retarget root bone, whatever the accessor is called.
//
// 5.4 spells it GetRetargetRoot(); later engines renamed the same accessor over
// the same FName to GetPelvis(). Which engine did the rename is not something
// this file has to know: the first overload drops out of the overload set when
// GetPelvis does not exist, and the second when GetRetargetRoot does not, so
// whichever the engine has is the one that gets called.
template <typename RigType>
static auto MCPIKRigRetargetRootBoneImpl(const RigType* Rig, int) -> decltype(Rig->GetPelvis())
{
	return Rig->GetPelvis();
}

template <typename RigType>
static auto MCPIKRigRetargetRootBoneImpl(const RigType* Rig, long) -> decltype(Rig->GetRetargetRoot())
{
	return Rig->GetRetargetRoot();
}

template <typename RigType>
static FName MCPIKRigRetargetRootBone(const RigType* Rig)
{
	return Rig ? MCPIKRigRetargetRootBoneImpl(Rig, 0) : NAME_None;
}

TSharedPtr<FJsonValue> FAnimationHandlers::ReadIKRig(const TSharedPtr<FJsonObject>& Params)
{
	FString AssetPath;
	if (auto Err = RequireStringAlt(Params, TEXT("assetPath"), TEXT("path"), AssetPath)) return Err;

	UObject* LoadedAsset = UEditorAssetLibrary::LoadAsset(AssetPath);
	UIKRigDefinition* IKRig = Cast<UIKRigDefinition>(LoadedAsset);
	if (!IKRig)
	{
		return MCPError(FString::Printf(TEXT("Failed to load IKRigDefinition at '%s'"), *AssetPath));
	}

	auto Result = MCPSuccess();

	Result->SetStringField(TEXT("assetPath"), AssetPath);
	Result->SetStringField(TEXT("name"), IKRig->GetName());

	// Preview mesh
	USkeletalMesh* PreviewMesh = IKRig->GetPreviewMesh();
	if (PreviewMesh)
	{
		Result->SetStringField(TEXT("previewMesh"), PreviewMesh->GetPathName());
	}

	// Skeleton
	const FIKRigSkeleton& RigSkeleton = IKRig->GetSkeleton();
	TArray<TSharedPtr<FJsonValue>> BonesArray;
	for (int32 i = 0; i < RigSkeleton.BoneNames.Num(); ++i)
	{
		TSharedPtr<FJsonObject> BoneObj = MakeShared<FJsonObject>();
		BoneObj->SetStringField(TEXT("name"), RigSkeleton.BoneNames[i].ToString());
		BoneObj->SetNumberField(TEXT("index"), i);
		if (i < RigSkeleton.ParentIndices.Num())
		{
			BoneObj->SetNumberField(TEXT("parentIndex"), RigSkeleton.ParentIndices[i]);
		}
		BonesArray.Add(MakeShared<FJsonValueObject>(BoneObj));
	}
	Result->SetArrayField(TEXT("bones"), BonesArray);

	// Retarget chains
	const TArray<FBoneChain>& Chains = IKRig->GetRetargetChains();
	TArray<TSharedPtr<FJsonValue>> ChainsArray;
	for (const FBoneChain& Chain : Chains)
	{
		TSharedPtr<FJsonObject> ChainObj = MakeShared<FJsonObject>();
		ChainObj->SetStringField(TEXT("name"), Chain.ChainName.ToString());
		ChainObj->SetStringField(TEXT("startBone"), Chain.StartBone.BoneName.ToString());
		ChainObj->SetStringField(TEXT("endBone"), Chain.EndBone.BoneName.ToString());
#if UE_MCP_HAS_5_8_API
		ChainObj->SetStringField(TEXT("goal"), Chain.IKGoalName.ToString());
#endif
		ChainsArray.Add(MakeShared<FJsonValueObject>(ChainObj));
	}
	Result->SetArrayField(TEXT("retargetChains"), ChainsArray);

	// #766: the retarget root (pelvis) and skeleton root drive how a retargeter
	// moves the whole character. Omitting them meant a caller inspecting a rig
	// could not tell whether the root was set at all, and had to open the
	// editor to find out.
	Result->SetStringField(TEXT("retargetRoot"), MCPIKRigRetargetRootBone(IKRig).ToString());
	if (RigSkeleton.BoneNames.Num() > 0)
	{
		Result->SetStringField(TEXT("rootBone"), RigSkeleton.BoneNames[0].ToString());
	}

	// UE 5.8 replaced the legacy UObject solver array with typed instanced
	// structs. Keep the reflection fallback for older supported engines.
	TArray<TSharedPtr<FJsonValue>> SolversArray;
#if UE_MCP_HAS_5_8_API
	UIKRigController* Controller = UIKRigController::GetController(IKRig);
	if (!Controller)
	{
		return MCPError(TEXT("IKRigController unavailable"));
	}

	Result->SetStringField(TEXT("retargetRoot"), Controller->GetRetargetRoot().ToString());
	Result->SetStringField(TEXT("rootMotionBone"), Controller->GetRootMotionBone().ToString());
	Result->SetStringField(
		TEXT("skeletonRootBone"),
		RigSkeleton.BoneNames.IsEmpty() ? TEXT("") : RigSkeleton.BoneNames[0].ToString());

	TArray<TSharedPtr<FJsonValue>> GoalsArray;
	for (UIKRigEffectorGoal* Goal : Controller->GetAllGoals())
	{
		if (!Goal) continue;
		TSharedPtr<FJsonObject> GoalObj = MakeShared<FJsonObject>();
		GoalObj->SetStringField(TEXT("name"), Goal->GoalName.ToString());
		GoalObj->SetStringField(TEXT("bone"), Goal->BoneName.ToString());
		GoalObj->SetNumberField(TEXT("positionAlpha"), Goal->PositionAlpha);
		GoalObj->SetNumberField(TEXT("rotationAlpha"), Goal->RotationAlpha);
		GoalObj->SetObjectField(TEXT("currentTransform"), AnimationTransformToJson(Goal->CurrentTransform));
		GoalObj->SetObjectField(TEXT("initialTransform"), AnimationTransformToJson(Goal->InitialTransform));

		TArray<TSharedPtr<FJsonValue>> ConnectedSolvers;
		for (int32 SolverIndex = 0; SolverIndex < Controller->GetNumSolvers(); ++SolverIndex)
		{
			if (Controller->IsGoalConnectedToSolver(Goal->GoalName, SolverIndex))
			{
				ConnectedSolvers.Add(MakeShared<FJsonValueNumber>(SolverIndex));
			}
		}
		GoalObj->SetArrayField(TEXT("connectedSolverIndices"), ConnectedSolvers);
		GoalsArray.Add(MakeShared<FJsonValueObject>(GoalObj));
	}
	Result->SetArrayField(TEXT("goals"), GoalsArray);

	TArray<TSharedPtr<FJsonValue>> ExcludedBones;
	for (const FName BoneName : RigSkeleton.BoneNames)
	{
		if (Controller->GetBoneExcluded(BoneName))
		{
			ExcludedBones.Add(MakeShared<FJsonValueString>(BoneName.ToString()));
		}
	}
	Result->SetArrayField(TEXT("excludedBones"), ExcludedBones);

	for (int32 SolverIndex = 0; SolverIndex < Controller->GetNumSolvers(); ++SolverIndex)
	{
		FInstancedStruct* SolverStruct = Controller->GetSolverStructAtIndex(SolverIndex);
		const UScriptStruct* SolverType = SolverStruct ? SolverStruct->GetScriptStruct() : nullptr;
		TSharedPtr<FJsonObject> SolverObj = MakeShared<FJsonObject>();
		SolverObj->SetNumberField(TEXT("index"), SolverIndex);
		SolverObj->SetStringField(TEXT("name"), Controller->GetSolverUniqueName(SolverIndex));
		SolverObj->SetStringField(TEXT("type"), SolverType ? SolverType->GetPathName() : TEXT(""));
		SolverObj->SetBoolField(TEXT("enabled"), Controller->GetSolverEnabled(SolverIndex));
		SolverObj->SetStringField(TEXT("startBone"), Controller->GetStartBone(SolverIndex).ToString());
		SolverObj->SetStringField(TEXT("endBone"), Controller->GetEndBone(SolverIndex).ToString());

		TArray<TSharedPtr<FJsonValue>> Effectors;
		if (UIKRigFBIKController* FBIKController = Cast<UIKRigFBIKController>(Controller->GetSolverController(SolverIndex)))
		{
			SolverObj->SetBoolField(TEXT("fullBodyIK"), true);
			for (UIKRigEffectorGoal* Goal : Controller->GetAllGoals())
			{
				if (!Goal || !Controller->IsGoalConnectedToSolver(Goal->GoalName, SolverIndex)) continue;
				const FIKRigFBIKGoalSettings Settings = FBIKController->GetGoalSettings(Goal->GoalName);
				TSharedPtr<FJsonObject> EffectorObj = MakeShared<FJsonObject>();
				EffectorObj->SetStringField(TEXT("goal"), Goal->GoalName.ToString());
				EffectorObj->SetStringField(TEXT("bone"), Settings.BoneName.ToString());
				EffectorObj->SetNumberField(TEXT("chainDepth"), Settings.ChainDepth);
				EffectorObj->SetNumberField(TEXT("strengthAlpha"), Settings.StrengthAlpha);
				EffectorObj->SetNumberField(TEXT("pullChainAlpha"), Settings.PullChainAlpha);
				EffectorObj->SetNumberField(TEXT("pinRotation"), Settings.PinRotation);
				Effectors.Add(MakeShared<FJsonValueObject>(EffectorObj));
			}
		}
		else
		{
			SolverObj->SetBoolField(TEXT("fullBodyIK"), false);
		}
		SolverObj->SetArrayField(TEXT("effectors"), Effectors);
		SolversArray.Add(MakeShared<FJsonValueObject>(SolverObj));
	}
#else
	FProperty* SolversProp = IKRig->GetClass()->FindPropertyByName(TEXT("Solvers"));
	if (SolversProp)
	{
		FString SolversStr;
		const void* ValPtr = SolversProp->ContainerPtrToValuePtr<void>(IKRig);
		SolversProp->ExportText_Direct(SolversStr, ValPtr, ValPtr, IKRig, PPF_None);
		if (!SolversStr.IsEmpty())
		{
			TSharedPtr<FJsonObject> SolverInfo = MakeShared<FJsonObject>();
			SolverInfo->SetStringField(TEXT("raw"), SolversStr);
			SolversArray.Add(MakeShared<FJsonValueObject>(SolverInfo));
		}
	}
#endif
	Result->SetArrayField(TEXT("solvers"), SolversArray);

	return MCPResult(Result);
}

// ─── #11  list_control_rig_variables ────────────────────────────────


// ─── #98 create_ik_retargeter ────────────────────────────────────────
TSharedPtr<FJsonValue> FAnimationHandlers::CreateIKRetargeter(const TSharedPtr<FJsonObject>& Params)
{
	FString Name;
	if (auto Err = RequireString(Params, TEXT("name"), Name)) return Err;
	FString PackagePath = OptionalString(Params, TEXT("packagePath"), TEXT("/Game"));
	FString SourceRigPath = OptionalString(Params, TEXT("sourceRig"));
	FString TargetRigPath = OptionalString(Params, TEXT("targetRig"));
	const FString OnConflict = OptionalString(Params, TEXT("onConflict"), TEXT("skip"));

	UClass* FactoryClass = FindObject<UClass>(nullptr, TEXT("/Script/IKRigEditor.IKRetargetFactory"));
	if (!FactoryClass)
	{
		return MCPError(TEXT("IKRetargetFactory not found - is the IKRig plugin enabled?"));
	}
	UClass* RetargeterClass = FindObject<UClass>(nullptr, TEXT("/Script/IKRig.IKRetargeter"));
	if (!RetargeterClass)
	{
		return MCPError(TEXT("IKRetargeter class not found"));
	}
	UFactory* Factory = NewObject<UFactory>(GetTransientPackage(), FactoryClass);

	auto Created = MCPCreateAssetIdempotent<UObject>(Name, PackagePath, OnConflict, TEXT("IKRetargeter"), RetargeterClass, Factory);
	if (Created.EarlyReturn) return Created.EarlyReturn;
	UObject* NewAsset = Created.Asset;
	const bool bAutoMap = OptionalBool(Params, TEXT("autoMapChains"), true);
	int32 ChainsMapped = 0;
	FString SrcErr;
	FString TgtErr;
	FString OpsWarning;

#if UE_MCP_HAS_5_8_API
	UIKRetargeter* Retargeter = Cast<UIKRetargeter>(NewAsset);
	if (!Retargeter)
	{
		UEditorAssetLibrary::DeleteAsset(NewAsset->GetPathName());
		return MCPError(TEXT("Created asset is not an IKRetargeter"));
	}

	UIKRigDefinition* SourceRig = SourceRigPath.IsEmpty()
		? nullptr
		: LoadObject<UIKRigDefinition>(nullptr, *SourceRigPath);
	UIKRigDefinition* TargetRig = TargetRigPath.IsEmpty()
		? nullptr
		: LoadObject<UIKRigDefinition>(nullptr, *TargetRigPath);
	if (!SourceRigPath.IsEmpty() && !SourceRig)
	{
		UEditorAssetLibrary::DeleteAsset(NewAsset->GetPathName());
		return MCPError(FString::Printf(TEXT("IKRig not found: %s"), *SourceRigPath));
	}
	if (!TargetRigPath.IsEmpty() && !TargetRig)
	{
		UEditorAssetLibrary::DeleteAsset(NewAsset->GetPathName());
		return MCPError(FString::Printf(TEXT("IKRig not found: %s"), *TargetRigPath));
	}

	UIKRetargeterController* Controller = UIKRetargeterController::GetController(Retargeter);
	if (!Controller)
	{
		UEditorAssetLibrary::DeleteAsset(NewAsset->GetPathName());
		return MCPError(TEXT("IKRetargeterController unavailable"));
	}

	// The factory does not install the operational stack. AddDefaultOps is
	// idempotent and must precede per-op rig assignment and chain mapping.
	Controller->AddDefaultOps();
	if (SourceRig)
	{
		Controller->SetIKRig(ERetargetSourceOrTarget::Source, SourceRig);
		Controller->AssignIKRigToAllOps(ERetargetSourceOrTarget::Source, SourceRig);
	}
	if (TargetRig)
	{
		Controller->SetIKRig(ERetargetSourceOrTarget::Target, TargetRig);
		Controller->AssignIKRigToAllOps(ERetargetSourceOrTarget::Target, TargetRig);
	}
	if (bAutoMap)
	{
		Controller->AutoMapChains(EAutoMapChainType::Exact, true);
	}
	Controller->CleanAsset();

	if ((SourceRig && Controller->GetIKRig(ERetargetSourceOrTarget::Source) != SourceRig)
		|| (TargetRig && Controller->GetIKRig(ERetargetSourceOrTarget::Target) != TargetRig))
	{
		UEditorAssetLibrary::DeleteAsset(NewAsset->GetPathName());
		return MCPError(TEXT("IK Retargeter rig assignment failed readback validation"));
	}

	if (TargetRig)
	{
		for (const FBoneChain& Chain : TargetRig->GetRetargetChains())
		{
			if (!Controller->GetSourceChain(Chain.ChainName).IsNone()) ++ChainsMapped;
		}
	}
#else
	// Optionally set source / target IK Rigs via reflection
	auto SetRigProperty = [&](const FString& PropName, const FString& Path) -> FString
	{
		if (Path.IsEmpty()) return TEXT("");
		UObject* Rig = LoadObject<UObject>(nullptr, *Path);
		if (!Rig) return FString::Printf(TEXT("IKRig not found: %s"), *Path);
		FProperty* Prop = NewAsset->GetClass()->FindPropertyByName(FName(*PropName));
		if (!Prop) return FString::Printf(TEXT("Property not found: %s"), *PropName);
		FString Export = Rig->GetPathName();
		void* Addr = Prop->ContainerPtrToValuePtr<void>(NewAsset);
		return Prop->ImportText_Direct(*Export, Addr, NewAsset, PPF_None) ? TEXT("") : FString::Printf(TEXT("Failed to set %s"), *PropName);
	};

	SrcErr = SetRigProperty(TEXT("SourceIKRigAsset"), SourceRigPath);
	TgtErr = SetRigProperty(TEXT("TargetIKRigAsset"), TargetRigPath);

	// UE 5.7+ ops-stack initialization (#246). After CreateAsset the per-op
	// IK Rig refs and chain mappings are unset, so the retargeter cannot be
	// driven by an Anim Graph. Mirror what the Python workaround does:
	// AssignIKRigToAllOps(SOURCE/TARGET) + AutoMapChains.
	if (bAutoMap)
	{
		UIKRetargeter* Retargeter = Cast<UIKRetargeter>(NewAsset);
		if (Retargeter)
		{
			UIKRetargeterController* Controller = UIKRetargeterController::GetController(Retargeter);
			if (Controller)
			{
#if UE_MCP_HAS_5_5_API
#if ENGINE_MAJOR_VERSION > 5 || (ENGINE_MAJOR_VERSION == 5 && ENGINE_MINOR_VERSION >= 7)
				// #753/#766: the factory creates a retargeter with an EMPTY ops
				// stack. On 5.7+ the chain mappings live ON the ops, so both
				// AssignIKRigToAllOps and AutoMapChains below have nothing to
				// map into and silently no-op - the handler reported
				// chainsMapped: 0 and produced a retargeter that animates
				// nothing. The IK Retargeter editor masks this because opening
				// the asset adds the default ops for you. Install them here so
				// the native path produces a working retargeter.
				if (Controller->GetNumRetargetOps() == 0)
				{
					Controller->AddDefaultOps();
				}
#endif
				if (!SourceRigPath.IsEmpty())
				{
					if (UIKRigDefinition* SrcRig = Cast<UIKRigDefinition>(LoadObject<UObject>(nullptr, *SourceRigPath)))
					{
#if ENGINE_MAJOR_VERSION > 5 || (ENGINE_MAJOR_VERSION == 5 && ENGINE_MINOR_VERSION >= 7)
						Controller->AssignIKRigToAllOps(ERetargetSourceOrTarget::Source, SrcRig);
#else
						Controller->SetIKRig(ERetargetSourceOrTarget::Source, SrcRig);
#endif
					}
				}
				if (!TargetRigPath.IsEmpty())
				{
					if (UIKRigDefinition* TgtRig = Cast<UIKRigDefinition>(LoadObject<UObject>(nullptr, *TargetRigPath)))
					{
#if ENGINE_MAJOR_VERSION > 5 || (ENGINE_MAJOR_VERSION == 5 && ENGINE_MINOR_VERSION >= 7)
						Controller->AssignIKRigToAllOps(ERetargetSourceOrTarget::Target, TgtRig);
#else
						Controller->SetIKRig(ERetargetSourceOrTarget::Target, TgtRig);
#endif
					}
				}
				Controller->AutoMapChains(EAutoMapChainType::Exact, true);
				// AutoMapChains returns void in UE 5.7; count populated mappings by re-querying.
				if (const UIKRigDefinition* TgtRig2 = Controller->GetIKRig(ERetargetSourceOrTarget::Target))
				{
					for (const FBoneChain& C : TgtRig2->GetRetargetChains())
					{
						if (!Controller->GetSourceChain(C.ChainName).IsNone()) ChainsMapped++;
					}
				}
#else
				// 5.4 has no ops-stack: per-op rig assignment + AutoMapChains(EAutoMapChainType,bool)
				// don't exist. The retargeter still works from SourceIKRigAsset/TargetIKRigAsset
				// properties; chain auto-mapping must be triggered manually in the IK Retargeter editor.
				OpsWarning = TEXT("autoMapChains requires UE 5.5+; open the IK Retargeter and auto-map chains manually.");
#endif
			}
			else
			{
				OpsWarning = TEXT("IKRetargeterController unavailable - chains not mapped (call autoMapChains=false to suppress)");
			}
		}
	}
#endif

	if (!SaveAssetPackage(NewAsset))
	{
		const FString FailedPackage = NewAsset->GetOutermost()->GetName();
		UEditorAssetLibrary::DeleteAsset(NewAsset->GetPathName());
		return MCPError(FString::Printf(TEXT("Failed to save IKRetargeter package '%s'"), *FailedPackage));
	}

	auto Result = MCPSuccess();
	MCPSetCreated(Result);
	Result->SetStringField(TEXT("assetPath"), NewAsset->GetPathName());
	Result->SetStringField(TEXT("name"), Name);
	if (!SrcErr.IsEmpty()) Result->SetStringField(TEXT("sourceRigWarning"), SrcErr);
	if (!TgtErr.IsEmpty()) Result->SetStringField(TEXT("targetRigWarning"), TgtErr);
	if (bAutoMap)
	{
		Result->SetNumberField(TEXT("chainsMapped"), ChainsMapped);
		if (!OpsWarning.IsEmpty()) Result->SetStringField(TEXT("opsWarning"), OpsWarning);
	}
	MCPSetDeleteAssetRollback(Result, NewAsset->GetPathName());
	return MCPResult(Result);
}

// ─── #246  read_ik_retargeter ──────────────────────────────────────────
TSharedPtr<FJsonValue> FAnimationHandlers::ReadIKRetargeter(const TSharedPtr<FJsonObject>& Params)
{
	FString AssetPath;
	if (auto Err = RequireString(Params, TEXT("assetPath"), AssetPath)) return Err;

	UIKRetargeter* Retargeter = Cast<UIKRetargeter>(LoadObject<UObject>(nullptr, *AssetPath));
	if (!Retargeter)
	{
		return MCPError(FString::Printf(TEXT("IKRetargeter not found: %s"), *AssetPath));
	}

	auto Result = MCPSuccess();
	Result->SetStringField(TEXT("assetPath"), AssetPath);

	UIKRetargeterController* Controller = UIKRetargeterController::GetController(Retargeter);
	if (!Controller)
	{
		Result->SetStringField(TEXT("warning"), TEXT("IKRetargeterController unavailable - returning shallow data"));
		return MCPResult(Result);
	}

	const UIKRigDefinition* SrcRig = Controller->GetIKRig(ERetargetSourceOrTarget::Source);
	const UIKRigDefinition* TgtRig = Controller->GetIKRig(ERetargetSourceOrTarget::Target);
	Result->SetStringField(TEXT("sourceRig"), SrcRig ? SrcRig->GetPathName() : TEXT(""));
	Result->SetStringField(TEXT("targetRig"), TgtRig ? TgtRig->GetPathName() : TEXT(""));

#if UE_MCP_HAS_5_8_API
	USkeletalMesh* SourcePreviewMesh = Controller->GetPreviewMesh(ERetargetSourceOrTarget::Source);
	USkeletalMesh* TargetPreviewMesh = Controller->GetPreviewMesh(ERetargetSourceOrTarget::Target);
	Result->SetStringField(TEXT("sourcePreviewMesh"), SourcePreviewMesh ? SourcePreviewMesh->GetPathName() : TEXT(""));
	Result->SetStringField(TEXT("targetPreviewMesh"), TargetPreviewMesh ? TargetPreviewMesh->GetPathName() : TEXT(""));

	auto WritePoses = [&](const ERetargetSourceOrTarget Side, const TCHAR* CurrentField, const TCHAR* PosesField)
	{
		const FName CurrentPoseName = Controller->GetCurrentRetargetPoseName(Side);
		Result->SetStringField(CurrentField, CurrentPoseName.ToString());

		TMap<FName, FIKRetargetPose>& Poses = Controller->GetRetargetPoses(Side);
		TArray<FName> PoseNames;
		Poses.GetKeys(PoseNames);
		PoseNames.Sort([](const FName& A, const FName& B)
		{
			return A.ToString() < B.ToString();
		});

		TArray<TSharedPtr<FJsonValue>> PosesArray;
		for (const FName PoseName : PoseNames)
		{
			const FIKRetargetPose* Pose = Poses.Find(PoseName);
			if (!Pose) continue;
			TSharedPtr<FJsonObject> PoseObj = MakeShared<FJsonObject>();
			PoseObj->SetStringField(TEXT("name"), PoseName.ToString());
			PoseObj->SetBoolField(TEXT("current"), PoseName == CurrentPoseName);
			PoseObj->SetObjectField(TEXT("rootTranslationOffset"), AnimationVectorToJson(Pose->GetRootTranslationDelta()));

			TArray<FName> OffsetBones;
			Pose->GetAllDeltaRotations().GetKeys(OffsetBones);
			OffsetBones.Sort([](const FName& A, const FName& B)
			{
				return A.ToString() < B.ToString();
			});

			TArray<TSharedPtr<FJsonValue>> RotationOffsets;
			for (const FName BoneName : OffsetBones)
			{
				const FQuat* Rotation = Pose->GetAllDeltaRotations().Find(BoneName);
				if (!Rotation) continue;
				TSharedPtr<FJsonObject> OffsetObj = MakeShared<FJsonObject>();
				OffsetObj->SetStringField(TEXT("bone"), BoneName.ToString());
				OffsetObj->SetObjectField(TEXT("rotationQuaternion"), AnimationQuaternionToJson(*Rotation));
				RotationOffsets.Add(MakeShared<FJsonValueObject>(OffsetObj));
			}
			PoseObj->SetArrayField(TEXT("rotationOffsets"), RotationOffsets);
			PosesArray.Add(MakeShared<FJsonValueObject>(PoseObj));
		}
		Result->SetArrayField(PosesField, PosesArray);
	};

	WritePoses(ERetargetSourceOrTarget::Source, TEXT("currentSourcePose"), TEXT("sourcePoses"));
	WritePoses(ERetargetSourceOrTarget::Target, TEXT("currentTargetPose"), TEXT("targetPoses"));

	TArray<TSharedPtr<FJsonValue>> RetargetOps;
	for (int32 OpIndex = 0; OpIndex < Controller->GetNumRetargetOps(); ++OpIndex)
	{
		const FName OpName = Controller->GetOpName(OpIndex);
		FInstancedStruct* OpStruct = Controller->GetRetargetOpStructAtIndex(OpIndex);
		const UScriptStruct* OpType = OpStruct ? OpStruct->GetScriptStruct() : nullptr;
		TSharedPtr<FJsonObject> OpObj = MakeShared<FJsonObject>();
		OpObj->SetNumberField(TEXT("index"), OpIndex);
		OpObj->SetStringField(TEXT("name"), OpName.ToString());
		OpObj->SetStringField(TEXT("type"), OpType ? OpType->GetPathName() : TEXT(""));
		OpObj->SetBoolField(TEXT("enabled"), Controller->GetRetargetOpEnabled(OpIndex));
		OpObj->SetStringField(TEXT("parentOp"), Controller->GetParentOpByName(OpName).ToString());
		const UIKRigDefinition* OpTargetRig = Controller->GetTargetIKRigForOp(OpName);
		OpObj->SetStringField(TEXT("targetRig"), OpTargetRig ? OpTargetRig->GetPathName() : TEXT(""));

		TArray<TSharedPtr<FJsonValue>> OpMappings;
		if (const FRetargetChainMapping* ChainMapping = Controller->GetChainMapping(OpName))
		{
			for (const FRetargetChainPair& Pair : ChainMapping->GetChainPairs())
			{
				TSharedPtr<FJsonObject> MappingObj = MakeShared<FJsonObject>();
				MappingObj->SetStringField(TEXT("targetChain"), Pair.TargetChainName.ToString());
				MappingObj->SetStringField(TEXT("sourceChain"), Pair.SourceChainName.ToString());
				OpMappings.Add(MakeShared<FJsonValueObject>(MappingObj));
			}
		}
		OpObj->SetArrayField(TEXT("chainMappings"), OpMappings);

		// #1000: the settings struct is what decides what an op does - the root
		// motion source, the pelvis alphas, the per-chain FK modes - and this
		// readout reported everything about an op except that. It is reflected,
		// so it serialises whole and a later engine adding a field needs no
		// change here.
		if (const FIKRetargetOpBase* const Op = OpStruct ? OpStruct->GetPtr<FIKRetargetOpBase>() : nullptr)
		{
			if (const UScriptStruct* const SettingsType = Op->GetSettingsType())
			{
				OpObj->SetStringField(TEXT("settingsType"), SettingsType->GetPathName());
				if (const FIKRetargetOpSettingsBase* const Settings = Op->GetSettingsConst())
				{
					const TSharedRef<FJsonObject> SettingsJson = MakeShared<FJsonObject>();
					if (FJsonObjectConverter::UStructToJsonObject(SettingsType, Settings, SettingsJson, 0, 0))
					{
						OpObj->SetObjectField(TEXT("settings"), SettingsJson);
					}
				}
			}
		}
		RetargetOps.Add(MakeShared<FJsonValueObject>(OpObj));
	}
	Result->SetArrayField(TEXT("retargetOps"), RetargetOps);
#endif

	// Chain mappings: for each target chain, report the source chain it's mapped to.
	TArray<TSharedPtr<FJsonValue>> Mappings;
	if (TgtRig)
	{
		const TArray<FBoneChain>& TargetChains = TgtRig->GetRetargetChains();
		for (const FBoneChain& TgtChain : TargetChains)
		{
			TSharedPtr<FJsonObject> Entry = MakeShared<FJsonObject>();
			Entry->SetStringField(TEXT("targetChain"), TgtChain.ChainName.ToString());
			FName SourceChain = Controller->GetSourceChain(TgtChain.ChainName);
			Entry->SetStringField(TEXT("sourceChain"), SourceChain.IsNone() ? TEXT("") : SourceChain.ToString());
			Mappings.Add(MakeShared<FJsonValueObject>(Entry));
		}
	}
	Result->SetArrayField(TEXT("chainMappings"), Mappings);
	return MCPResult(Result);
}

// ─── #99 set_anim_blueprint_skeleton ──────────────────────────────────


// ===========================================================================
// v0.7.15 - PoseSearch (motion matching)
// ===========================================================================

TSharedPtr<FJsonValue> FAnimationHandlers::CreatePoseSearchDatabase(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/MotionMatching"));
	const FString SchemaPath = OptionalString(Params, TEXT("schemaPath"), TEXT(""));
	// #833: a database indexes THROUGH its schema, so a database whose schema
	// is missing or unsamplable fails BuildIndex and the editor reports the
	// database as the invalid asset. Naming a skeleton is enough to author the
	// schema here, so the one call produces something that can actually index.
	const FString SkeletonPath = OptionalString(Params, TEXT("skeletonPath"), TEXT(""));

	// Resolve (or author) the schema BEFORE the database exists, so a bad
	// schema argument leaves no half-built database behind.
	UPoseSearchSchema* Schema = nullptr;
	FString ResolvedSchemaPath = SchemaPath;
	bool bSchemaCreated = false;
	if (!SchemaPath.IsEmpty())
	{
		Schema = Cast<UPoseSearchSchema>(UEditorAssetLibrary::LoadAsset(SchemaPath));
		if (!Schema) return MCPError(FString::Printf(TEXT("Schema not found: %s"), *SchemaPath));
	}
	else if (!SkeletonPath.IsEmpty())
	{
		ResolvedSchemaPath = PackagePath + TEXT("/") + Name + TEXT("_Schema");
		Schema = Cast<UPoseSearchSchema>(UEditorAssetLibrary::LoadAsset(ResolvedSchemaPath));
		if (!Schema)
		{
			// Same authoring routine animation(create_pose_search_schema) runs,
			// called rather than reimplemented, so the schema this makes is the
			// schema that action makes.
			TSharedPtr<FJsonObject> SchemaParams = MakeShared<FJsonObject>();
			SchemaParams->SetStringField(TEXT("name"), Name + TEXT("_Schema"));
			SchemaParams->SetStringField(TEXT("packagePath"), PackagePath);
			SchemaParams->SetStringField(TEXT("skeletonPath"), SkeletonPath);
			SchemaParams->SetStringField(TEXT("onConflict"), TEXT("skip"));
			TSharedPtr<FJsonValue> SchemaResult = CreatePoseSearchSchema(SchemaParams);
			Schema = Cast<UPoseSearchSchema>(UEditorAssetLibrary::LoadAsset(ResolvedSchemaPath));
			if (!Schema) return SchemaResult;
			bSchemaCreated = true;
		}
	}

	if (Schema)
	{
		const FString Problems = MCPPoseSearch::DescribeProblems(Schema);
		if (!Problems.IsEmpty())
		{
			return MCPError(FString::Printf(
				TEXT("Schema '%s' cannot index because %s. A database pointed at it fails BuildIndex and the editor ")
				TEXT("reports the database as invalid, so no database was created."),
				*Schema->GetPathName(), *Problems));
		}
	}

	auto Created = MCPCreateAssetIdempotentNewObject<UPoseSearchDatabase>(Name, PackagePath, OptionalString(Params, TEXT("onConflict"), TEXT("skip")), TEXT("PoseSearchDatabase"));
	if (Created.EarlyReturn) return Created.EarlyReturn;
	UPoseSearchDatabase* Database = Created.Asset;

	if (Schema)
	{
		Database->Schema = Schema;
	}

	UEditorAssetLibrary::SaveLoadedAsset(Database);

	TSharedPtr<FJsonObject> Res = MCPSuccess();
	MCPSetCreated(Res);
	Res->SetStringField(TEXT("path"), Database->GetPathName());
	Res->SetStringField(TEXT("name"), Name);
	Res->SetStringField(TEXT("packagePath"), PackagePath);
	Res->SetStringField(TEXT("schemaPath"), Schema ? Schema->GetPathName() : ResolvedSchemaPath);
	Res->SetBoolField(TEXT("schemaCreated"), bSchemaCreated);
	Res->SetBoolField(TEXT("indexable"), Schema != nullptr);
	if (!Schema)
	{
		Res->SetStringField(TEXT("note"),
			TEXT("This database has no schema, so it cannot be indexed and animation(add_pose_search_sequence) will ")
			TEXT("refuse to add clips to it. Give it one with animation(set_pose_search_schema), or pass skeletonPath ")
			TEXT("to this action and it authors the schema alongside the database."));
	}
	MCPSetDeleteAssetRollback(Res, Database->GetPathName());
	return MCPResult(Res);
}


TSharedPtr<FJsonValue> FAnimationHandlers::SetPoseSearchSchema(const TSharedPtr<FJsonObject>& Params)
{
	FString AssetPath;
	if (auto Err = RequireStringAlt(Params, TEXT("path"), TEXT("assetPath"), AssetPath)) return Err;
	FString SchemaPath;
	if (auto Err = RequireString(Params, TEXT("schemaPath"), SchemaPath)) return Err;

	UPoseSearchDatabase* Database = Cast<UPoseSearchDatabase>(UEditorAssetLibrary::LoadAsset(AssetPath));
	if (!Database) return MCPError(FString::Printf(TEXT("PoseSearchDatabase not found: %s"), *AssetPath));

	UPoseSearchSchema* Schema = Cast<UPoseSearchSchema>(UEditorAssetLibrary::LoadAsset(SchemaPath));
	if (!Schema) return MCPError(FString::Printf(TEXT("Schema not found: %s"), *SchemaPath));
	// #833: assigning a schema that cannot index converts a database that was
	// merely empty into one the editor reports as invalid.
	const FString SchemaProblems = MCPPoseSearch::DescribeProblems(Schema);
	if (!SchemaProblems.IsEmpty())
	{
		return MCPError(FString::Printf(
			TEXT("Schema '%s' cannot index because %s. It was not assigned, because a database using it fails ")
			TEXT("BuildIndex and the editor then reports the database as the invalid asset."),
			*SchemaPath, *SchemaProblems));
	}

	const FString PrevSchemaPath = Database->Schema ? Database->Schema->GetPathName() : FString();
	Database->Modify();
	Database->Schema = Schema;
	Database->PostEditChange();
	UEditorAssetLibrary::SaveLoadedAsset(Database);

	TSharedPtr<FJsonObject> Res = MCPSuccess();
	MCPSetUpdated(Res);
	Res->SetStringField(TEXT("path"), AssetPath);
	Res->SetStringField(TEXT("schemaPath"), SchemaPath);
	Res->SetStringField(TEXT("previousSchemaPath"), PrevSchemaPath);

	TSharedPtr<FJsonObject> RbPayload = MakeShared<FJsonObject>();
	RbPayload->SetStringField(TEXT("assetPath"), AssetPath);
	RbPayload->SetStringField(TEXT("schemaPath"), PrevSchemaPath);
	if (!PrevSchemaPath.IsEmpty()) MCPSetRollback(Res, TEXT("set_pose_search_schema"), RbPayload);
	return MCPResult(Res);
}


TSharedPtr<FJsonValue> FAnimationHandlers::AddPoseSearchSequence(const TSharedPtr<FJsonObject>& Params)
{
	FString AssetPath;
	if (auto Err = RequireStringAlt(Params, TEXT("path"), TEXT("assetPath"), AssetPath)) return Err;
	FString SequencePath;
	if (auto Err = RequireString(Params, TEXT("sequencePath"), SequencePath)) return Err;

	UPoseSearchDatabase* Database = Cast<UPoseSearchDatabase>(UEditorAssetLibrary::LoadAsset(AssetPath));
	if (!Database) return MCPError(FString::Printf(TEXT("PoseSearchDatabase not found: %s"), *AssetPath));

	UObject* AnimAsset = UEditorAssetLibrary::LoadAsset(SequencePath);
	if (!AnimAsset) return MCPError(FString::Printf(TEXT("Animation asset not found: %s"), *SequencePath));

	// PoseSearch accepts AnimSequence, AnimComposite, AnimMontage, BlendSpace, MultiAnimAsset.
	if (!AnimAsset->IsA<UAnimSequenceBase>() && !AnimAsset->IsA<UBlendSpace>())
	{
		return MCPError(FString::Printf(TEXT("Animation asset type not supported by PoseSearch: %s"), *AnimAsset->GetClass()->GetName()));
	}

	// #833: clips are only meaningful once the database can index them. A
	// database that carries clips with no usable schema is the exact asset the
	// editor rejects, so the clip write is refused rather than saved.
	{
		const FString Refusal = MCPPoseSearch::DescribeClipRefusal(Database->Schema, AssetPath);
		if (!Refusal.IsEmpty()) return MCPError(Refusal);
	}

	// #684: optional per-clip flags (mirror / disableReselection / samplingRange / enabled).
	const FPoseSearchClipFlags Flags = ParsePoseSearchClipFlags(Params);

	const int32 PrevCount = GetPoseSearchAnimationAssetCount(Database);
	// Captured before the append, because the inverse of "append one clip" is
	// "put the whole list back": there is no remove-by-index action.
	int32 DroppedClipCount = 0;
	TArray<TSharedPtr<FJsonValue>> PrevClips = CapturePoseSearchClips(Database, DroppedClipCount);
	Database->Modify();
	FString AddError;
	if (!AddPoseSearchAnimationAsset(Database, AnimAsset, Flags, AddError))
	{
		return MCPError(AddError);
	}
	Database->PostEditChange();
	UEditorAssetLibrary::SaveLoadedAsset(Database);

	const int32 NewCount = GetPoseSearchAnimationAssetCount(Database);

	TSharedPtr<FJsonObject> Res = MCPSuccess();
	MCPSetUpdated(Res);
	Res->SetStringField(TEXT("path"), AssetPath);
	Res->SetStringField(TEXT("sequencePath"), SequencePath);
	Res->SetNumberField(TEXT("previousCount"), PrevCount);
	Res->SetNumberField(TEXT("newCount"), NewCount);
	Res->SetNumberField(TEXT("addedIndex"), NewCount - 1);

	// set_pose_search_clips with clearExisting replaces the whole list, so
	// replaying the captured list drops the clip this call appended and leaves
	// every other clip with the flags it had.
	TSharedPtr<FJsonObject> Rollback = MakeShared<FJsonObject>();
	Rollback->SetStringField(TEXT("assetPath"), AssetPath);
	Rollback->SetArrayField(TEXT("clips"), PrevClips);
	Rollback->SetBoolField(TEXT("clearExisting"), true);
	MCPSetRollback(Res, TEXT("set_pose_search_clips"), Rollback);
	Res->SetNumberField(TEXT("rollbackDroppedClipCount"), DroppedClipCount);
	Res->SetBoolField(TEXT("rollbackLossy"), true);
	Res->SetStringField(TEXT("rollbackNote"), FString::Printf(
		TEXT("The replay rebuilds the clip list from scratch, so every clip is re-added in the captured order with the captured enabled / disableReselection / mirror / sampling-range flags. ")
		TEXT("%d entr(ies) held no animation asset and cannot be described to set_pose_search_clips, so the replay does not bring them back. ")
		TEXT("A database index built over the old list is not restored: run animation(build_pose_search_index) after the rollback."),
		DroppedClipCount));
	return MCPResult(Res);
}


// #684: bulk clip-list authoring. Replaces (or appends to) the whole clip list in
// one call, with per-entry flags. This is the idiomatic "duplicate a stock PSD,
// swap its clips" pipeline step that add_pose_search_sequence can only do one clip
// at a time. Params: assetPath, clips[] ({sequencePath|asset, mirror?,
// disableReselection?, sampleStart?, sampleEnd?, enabled?}), clearExisting? (default true).
TSharedPtr<FJsonValue> FAnimationHandlers::SetPoseSearchClips(const TSharedPtr<FJsonObject>& Params)
{
	FString AssetPath;
	if (auto Err = RequireStringAlt(Params, TEXT("path"), TEXT("assetPath"), AssetPath)) return Err;

	UPoseSearchDatabase* Database = Cast<UPoseSearchDatabase>(UEditorAssetLibrary::LoadAsset(AssetPath));
	if (!Database) return MCPError(FString::Printf(TEXT("PoseSearchDatabase not found: %s"), *AssetPath));

	const TArray<TSharedPtr<FJsonValue>>* Clips = nullptr;
	if (!Params->TryGetArrayField(TEXT("clips"), Clips) || !Clips)
	{
		return MCPError(TEXT("Missing required parameter 'clips' (array of {sequencePath, mirror?, disableReselection?, sampleStart?, sampleEnd?, enabled?})"));
	}

	// #833: clips are only meaningful once the database can index them. A
	// database that carries clips with no usable schema is the exact asset the
	// editor rejects, so the clip write is refused rather than saved.
	{
		const FString Refusal = MCPPoseSearch::DescribeClipRefusal(Database->Schema, AssetPath);
		if (!Refusal.IsEmpty()) return MCPError(Refusal);
	}

	const bool bClearExisting = OptionalBool(Params, TEXT("clearExisting"), true);
	const int32 PrevCount = GetPoseSearchAnimationAssetCount(Database);
	// Captured before anything is cleared or appended: this action's own inverse
	// is itself, replaying the list that was there.
	int32 DroppedClipCount = 0;
	TArray<TSharedPtr<FJsonValue>> PrevClips = CapturePoseSearchClips(Database, DroppedClipCount);

	// Resolve every clip up front so a bad path fails the whole call before mutating.
	struct FResolvedClip { UObject* Asset; FPoseSearchClipFlags Flags; FString Path; };
	TArray<FResolvedClip> Resolved;
	Resolved.Reserve(Clips->Num());
	for (const TSharedPtr<FJsonValue>& ClipVal : *Clips)
	{
		const TSharedPtr<FJsonObject>* ClipObj = nullptr;
		FString ClipPath;
		if (ClipVal->TryGetObject(ClipObj) && ClipObj && (*ClipObj).IsValid())
		{
			if (!(*ClipObj)->TryGetStringField(TEXT("sequencePath"), ClipPath) &&
				!(*ClipObj)->TryGetStringField(TEXT("asset"), ClipPath) &&
				!(*ClipObj)->TryGetStringField(TEXT("assetPath"), ClipPath) &&
				!(*ClipObj)->TryGetStringField(TEXT("animationPath"), ClipPath))
			{
				return MCPError(TEXT("Each clip needs a 'sequencePath' (or 'asset'/'assetPath'/'animationPath')"));
			}
		}
		else if (!ClipVal->TryGetString(ClipPath))
		{
			return MCPError(TEXT("Each clip must be an object or an animation asset path string"));
		}

		UObject* AnimAsset = UEditorAssetLibrary::LoadAsset(ClipPath);
		if (!AnimAsset) return MCPError(FString::Printf(TEXT("Animation asset not found: %s"), *ClipPath));
		if (!AnimAsset->IsA<UAnimSequenceBase>() && !AnimAsset->IsA<UBlendSpace>())
		{
			return MCPError(FString::Printf(TEXT("Animation asset type not supported by PoseSearch: %s"), *AnimAsset->GetClass()->GetName()));
		}

		FResolvedClip RC;
		RC.Asset = AnimAsset;
		RC.Path = ClipPath;
		RC.Flags = (ClipObj && (*ClipObj).IsValid()) ? ParsePoseSearchClipFlags(*ClipObj) : FPoseSearchClipFlags();
		Resolved.Add(MoveTemp(RC));
	}

	Database->Modify();

#if UE_MCP_HAS_POSESEARCH_DATABASE_ASSET_API
	if (bClearExisting)
	{
		for (int32 i = GetPoseSearchAnimationAssetCount(Database) - 1; i >= 0; --i)
		{
			Database->RemoveAnimationAssetAt(i);
		}
	}
#else
	if (bClearExisting) Database->AnimationAssets.Empty();
#endif

	TArray<TSharedPtr<FJsonValue>> Added;
	for (const FResolvedClip& RC : Resolved)
	{
		FString AddError;
		if (!AddPoseSearchAnimationAsset(Database, RC.Asset, RC.Flags, AddError))
		{
			return MCPError(AddError);
		}
		Added.Add(MakeShared<FJsonValueString>(RC.Asset->GetPathName()));
	}

	Database->PostEditChange();
	UEditorAssetLibrary::SaveLoadedAsset(Database);

	TSharedPtr<FJsonObject> Res = MCPSuccess();
	MCPSetUpdated(Res);
	Res->SetStringField(TEXT("path"), AssetPath);
	Res->SetBoolField(TEXT("clearedExisting"), bClearExisting);
	Res->SetNumberField(TEXT("previousCount"), PrevCount);
	Res->SetNumberField(TEXT("addedCount"), Added.Num());
	Res->SetNumberField(TEXT("newCount"), GetPoseSearchAnimationAssetCount(Database));
	Res->SetArrayField(TEXT("addedAssets"), Added);

	TSharedPtr<FJsonObject> Rollback = MakeShared<FJsonObject>();
	Rollback->SetStringField(TEXT("assetPath"), AssetPath);
	Rollback->SetArrayField(TEXT("clips"), PrevClips);
	Rollback->SetBoolField(TEXT("clearExisting"), true);
	MCPSetRollback(Res, TEXT("set_pose_search_clips"), Rollback);
	Res->SetNumberField(TEXT("rollbackDroppedClipCount"), DroppedClipCount);
	Res->SetBoolField(TEXT("rollbackLossy"), true);
	Res->SetStringField(TEXT("rollbackNote"), FString::Printf(
		TEXT("The replay rebuilds the clip list from scratch with the captured enabled / disableReselection / mirror / sampling-range flags per clip. ")
		TEXT("%d entr(ies) held no animation asset and cannot be described to set_pose_search_clips, so the replay does not bring them back. ")
		TEXT("A database index built over the old list is not restored: run animation(build_pose_search_index) after the rollback."),
		DroppedClipCount));
	return MCPResult(Res);
}


TSharedPtr<FJsonValue> FAnimationHandlers::BuildPoseSearchIndex(const TSharedPtr<FJsonObject>& Params)
{
	FString AssetPath;
	if (auto Err = RequireStringAlt(Params, TEXT("path"), TEXT("assetPath"), AssetPath)) return Err;
	const bool bWait = OptionalBool(Params, TEXT("wait"), true);

	UPoseSearchDatabase* Database = Cast<UPoseSearchDatabase>(UEditorAssetLibrary::LoadAsset(AssetPath));
	if (!Database) return MCPError(FString::Printf(TEXT("PoseSearchDatabase not found: %s"), *AssetPath));
	if (!Database->Schema) return MCPError(TEXT("Database has no Schema set - call set_pose_search_schema first"));
	if (GetPoseSearchAnimationAssetCount(Database) == 0) return MCPError(TEXT("Database has no animation assets - call add_pose_search_sequence first"));

	using namespace UE::PoseSearch;
	const ERequestAsyncBuildFlag Flag = bWait
		? (ERequestAsyncBuildFlag::NewRequest | ERequestAsyncBuildFlag::WaitForCompletion)
		: ERequestAsyncBuildFlag::NewRequest;
	// RequestAsyncBuildIndex returns EAsyncBuildIndexResult on 5.4 through 5.8
	// alike; only the clip-list accessors moved, and they are gated separately.
	const EAsyncBuildIndexResult Result = FAsyncPoseSearchDatabasesManagement::RequestAsyncBuildIndex(Database, Flag);

	FString ResultStr;
	bool bSuccess = false;
	switch (Result)
	{
		case EAsyncBuildIndexResult::Success:    ResultStr = TEXT("Success"); bSuccess = true; break;
		case EAsyncBuildIndexResult::InProgress: ResultStr = TEXT("InProgress"); bSuccess = true; break;
		case EAsyncBuildIndexResult::Failed:     ResultStr = TEXT("Failed"); break;
	}

	UEditorAssetLibrary::SaveLoadedAsset(Database);

	TSharedPtr<FJsonObject> Res = MCPSuccess();
	if (bSuccess) MCPSetUpdated(Res);
	Res->SetBoolField(TEXT("success"), bSuccess);
	Res->SetStringField(TEXT("path"), AssetPath);
	Res->SetStringField(TEXT("result"), ResultStr);
	Res->SetBoolField(TEXT("waitedForCompletion"), bWait);
	Res->SetNumberField(TEXT("animationAssetCount"), GetPoseSearchAnimationAssetCount(Database));
	Res->SetBoolField(TEXT("rollbackPossible"), false);
	Res->SetStringField(TEXT("rollbackNote"),
		TEXT("Building the index writes derived data computed from the schema and the clip list. There is no inverse action: nothing unbuilds an index, ")
		TEXT("and the index it replaced was derived data too, so it is not addressable to put back. Rebuilding from the restored inputs is the recovery."));
	return MCPResult(Res);
}


TSharedPtr<FJsonValue> FAnimationHandlers::ReadPoseSearchDatabase(const TSharedPtr<FJsonObject>& Params)
{
	FString AssetPath;
	if (auto Err = RequireStringAlt(Params, TEXT("path"), TEXT("assetPath"), AssetPath)) return Err;

	UPoseSearchDatabase* Database = Cast<UPoseSearchDatabase>(UEditorAssetLibrary::LoadAsset(AssetPath));
	if (!Database) return MCPError(FString::Printf(TEXT("PoseSearchDatabase not found: %s"), *AssetPath));

	TSharedPtr<FJsonObject> Res = MCPSuccess();
	Res->SetStringField(TEXT("path"), AssetPath);
	Res->SetStringField(TEXT("name"), Database->GetName());
	Res->SetStringField(TEXT("schemaPath"), Database->Schema ? Database->Schema->GetPathName() : FString());
#if UE_MCP_HAS_POSESEARCH_DATABASE_ASSET_API
	Res->SetNumberField(TEXT("continuingPoseCostBias"), Database->ContinuingPoseCostBias);
	Res->SetNumberField(TEXT("baseCostBias"), Database->BaseCostBias);
	Res->SetNumberField(TEXT("loopingCostBias"), Database->LoopingCostBias);
#endif
	Res->SetNumberField(TEXT("kdTreeQueryNumNeighbors"), Database->KDTreeQueryNumNeighbors);

	const int32 AssetCount = GetPoseSearchAnimationAssetCount(Database);
	Res->SetNumberField(TEXT("animationAssetCount"), AssetCount);

	TArray<TSharedPtr<FJsonValue>> Animations;
	for (int32 i = 0; i < AssetCount; ++i)
	{
#if UE_MCP_HAS_POSESEARCH_DATABASE_ASSET_API
		// Non-templated accessor from 5.7 on: the templated overload is
		// deprecated there and reinterpret_casts to the same base type this
		// code reads through.
	#if UE_MCP_HAS_5_7_API
		const FPoseSearchDatabaseAnimationAssetBase* Entry = Database->GetDatabaseAnimationAsset(i);
	#else
		const FPoseSearchDatabaseAnimationAssetBase* Entry = Database->GetDatabaseAnimationAsset<FPoseSearchDatabaseAnimationAssetBase>(i);
	#endif
		if (!Entry) continue;
		UObject* AnimationAsset = Entry->GetAnimationAsset();
#else
		const FPoseSearchDatabaseAnimationAssetBase* Entry = Database->GetAnimationAssetBase(i);
		if (!Entry) continue;
		UObject* AnimationAsset = Entry->GetAnimationAsset();
#endif
		TSharedPtr<FJsonObject> A = MakeShared<FJsonObject>();
		A->SetNumberField(TEXT("index"), i);
		A->SetStringField(TEXT("assetPath"), AnimationAsset ? AnimationAsset->GetPathName() : FString());
		A->SetStringField(TEXT("assetClass"), AnimationAsset ? AnimationAsset->GetClass()->GetName() : FString());
		A->SetBoolField(TEXT("isLooping"), Entry->IsLooping());
		A->SetBoolField(TEXT("isRootMotionEnabled"), Entry->IsRootMotionEnabled());
		Animations.Add(MakeShared<FJsonValueObject>(A));
	}
	Res->SetArrayField(TEXT("animationAssets"), Animations);

	TArray<TSharedPtr<FJsonValue>> Tags;
#if UE_MCP_HAS_POSESEARCH_DATABASE_ASSET_API
	for (const FName& T : Database->Tags) Tags.Add(MakeShared<FJsonValueString>(T.ToString()));
#endif
	Res->SetArrayField(TEXT("tags"), Tags);

	if (Database->Schema)
	{
		TSharedPtr<FJsonObject> SchemaObj = MakeShared<FJsonObject>();
		SchemaObj->SetStringField(TEXT("path"), Database->Schema->GetPathName());
		SchemaObj->SetNumberField(TEXT("sampleRate"), Database->Schema->SampleRate);
		// Channels/Skeletons are private members; use the public GetChannels() accessor
		// (returns the finalized channel set) and fall back to reflection for Skeletons.
		SchemaObj->SetNumberField(TEXT("channelCount"), Database->Schema->GetChannels().Num());
		int32 SkeletonCount = 0;
		if (FProperty* SkelProp = Database->Schema->GetClass()->FindPropertyByName(TEXT("Skeletons")))
		{
			if (FArrayProperty* ArrProp = CastField<FArrayProperty>(SkelProp))
			{
				FScriptArrayHelper Helper(ArrProp, ArrProp->ContainerPtrToValuePtr<void>(Database->Schema));
				SkeletonCount = Helper.Num();
			}
		}
		SchemaObj->SetNumberField(TEXT("skeletonCount"), SkeletonCount);
		Res->SetObjectField(TEXT("schema"), SchemaObj);
	}

	return MCPResult(Res);
}

// ─── #701 set_ik_rig_mesh ───────────────────────────────────────────
// Set the preview/source skeletal mesh on an EXISTING IK Rig.
TSharedPtr<FJsonValue> FAnimationHandlers::SetIKRigMesh(const TSharedPtr<FJsonObject>& Params)
{
	FString RigPath;
	if (auto Err = RequireStringAlt(Params, TEXT("rigPath"), TEXT("assetPath"), RigPath)) return Err;
	if (MCPIsProtectedAssetPath(RigPath))
	{
		return MCPError(FString::Printf(TEXT("Protected asset cannot be modified: %s"), *RigPath));
	}
	FString MeshPath;
	if (auto Err = RequireStringAlt(Params, TEXT("meshPath"), TEXT("skeletalMesh"), MeshPath)) return Err;

	UIKRigDefinition* IKRig = LoadObject<UIKRigDefinition>(nullptr, *RigPath);
	if (!IKRig) return MCPError(FString::Printf(TEXT("IKRig not found: %s"), *RigPath));
	USkeletalMesh* Mesh = LoadObject<USkeletalMesh>(nullptr, *MeshPath);
	if (!Mesh) return MCPError(FString::Printf(TEXT("SkeletalMesh not found: %s"), *MeshPath));

	UIKRigController* Controller = UIKRigController::GetController(IKRig);
	if (!Controller) return MCPError(TEXT("IKRigController unavailable"));
	USkeletalMesh* PrevMesh = Controller->GetSkeletalMesh();
	const FString PrevMeshPath = PrevMesh ? PrevMesh->GetPathName() : FString();
	const bool bOk = Controller->SetSkeletalMesh(Mesh);
	SaveAssetPackage(IKRig);

	auto Result = MCPSuccess();
	MCPSetUpdated(Result);
	Result->SetBoolField(TEXT("unchanged"), PrevMesh == Mesh);
	Result->SetStringField(TEXT("rigPath"), IKRig->GetPathName());
	Result->SetStringField(TEXT("skeletalMesh"), Mesh->GetPathName());
	Result->SetStringField(TEXT("previousSkeletalMesh"), PrevMeshPath);
	Result->SetBoolField(TEXT("applied"), bOk);

	if (!PrevMeshPath.IsEmpty())
	{
		TSharedPtr<FJsonObject> Rollback = MakeShared<FJsonObject>();
		Rollback->SetStringField(TEXT("rigPath"), IKRig->GetPathName());
		Rollback->SetStringField(TEXT("meshPath"), PrevMeshPath);
		MCPSetRollback(Result, TEXT("set_ik_rig_mesh"), Rollback);
		Result->SetBoolField(TEXT("rollbackLossy"), false);
	}
	else
	{
		Result->SetBoolField(TEXT("rollbackPossible"), false);
		Result->SetStringField(TEXT("rollbackNote"),
			TEXT("The rig had no skeletal mesh before this call. set_ik_rig_mesh requires a meshPath and cannot clear one, so there is no call that returns the rig to having none."));
	}
	return MCPResult(Result);
}

namespace
{
	ERetargetSourceOrTarget ParseSourceOrTarget(const FString& S)
	{
		return S.Equals(TEXT("source"), ESearchCase::IgnoreCase)
			? ERetargetSourceOrTarget::Source
			: ERetargetSourceOrTarget::Target;
	}
}

// ─── #703 set_ik_retargeter_rig ─────────────────────────────────────
TSharedPtr<FJsonValue> FAnimationHandlers::SetIKRetargeterRig(const TSharedPtr<FJsonObject>& Params)
{
	FString RetargeterPath;
	if (auto Err = RequireStringAlt(Params, TEXT("retargeterPath"), TEXT("assetPath"), RetargeterPath)) return Err;
	if (MCPIsProtectedAssetPath(RetargeterPath))
	{
		return MCPError(FString::Printf(TEXT("Protected asset cannot be modified: %s"), *RetargeterPath));
	}
	FString RigPath;
	if (auto Err = RequireStringAlt(Params, TEXT("rigPath"), TEXT("ikRig"), RigPath)) return Err;
	const FString Side = OptionalString(Params, TEXT("side"), TEXT("target"));
	if (!Side.Equals(TEXT("source"), ESearchCase::IgnoreCase)
		&& !Side.Equals(TEXT("target"), ESearchCase::IgnoreCase))
	{
		return MCPError(TEXT("'side' must be 'source' or 'target'"));
	}

	UIKRetargeter* Retargeter = LoadObject<UIKRetargeter>(nullptr, *RetargeterPath);
	if (!Retargeter) return MCPError(FString::Printf(TEXT("IKRetargeter not found: %s"), *RetargeterPath));
	UIKRigDefinition* IKRig = LoadObject<UIKRigDefinition>(nullptr, *RigPath);
	if (!IKRig) return MCPError(FString::Printf(TEXT("IKRig not found: %s"), *RigPath));

	UIKRetargeterController* Controller = UIKRetargeterController::GetController(Retargeter);
	if (!Controller) return MCPError(TEXT("IKRetargeterController unavailable"));
	const ERetargetSourceOrTarget SourceOrTarget = ParseSourceOrTarget(Side);
	// Read the rig this side already carries before the transaction opens: it is
	// the only value an inverse call can put back.
	const UIKRigDefinition* PrevRig = Controller->GetIKRig(SourceOrTarget);
	const FString PrevRigPath = PrevRig ? PrevRig->GetPathName() : FString();
	bool bAssignmentFailed = false;
	{
		const FScopedTransaction Transaction(NSLOCTEXT("UE_MCP", "SetIKRetargeterRig", "Set IK Retargeter Rig"));
		Retargeter->Modify();
#if UE_MCP_HAS_5_8_API
		if (Controller->GetNumRetargetOps() == 0)
		{
			Controller->AddDefaultOps();
		}
		Controller->SetIKRig(SourceOrTarget, IKRig);
		Controller->AssignIKRigToAllOps(SourceOrTarget, IKRig);
		Controller->CleanAsset();
#else
		Controller->SetIKRig(SourceOrTarget, IKRig);
#endif
		bAssignmentFailed = Controller->GetIKRig(SourceOrTarget) != IKRig;
	}
	if (bAssignmentFailed)
	{
		const bool bRolledBack = GEditor && GEditor->UndoTransaction();
		return MCPError(bRolledBack
			? TEXT("IK Retargeter rig assignment failed readback validation and was rolled back")
			: TEXT("IK Retargeter rig assignment failed readback validation and could not be rolled back"));
	}
	if (!SaveAssetPackage(Retargeter))
	{
		const bool bRolledBack = GEditor && GEditor->UndoTransaction();
		return MCPError(bRolledBack
			? TEXT("IK Retargeter rig assignment could not be saved and was rolled back")
			: TEXT("IK Retargeter rig assignment could not be saved and the editor transaction could not be rolled back"));
	}

	auto Result = MCPSuccess();
	MCPSetUpdated(Result);
	Result->SetStringField(TEXT("retargeterPath"), Retargeter->GetPathName());
	Result->SetStringField(TEXT("side"), Side.ToLower());
	Result->SetStringField(TEXT("ikRig"), IKRig->GetPathName());
	Result->SetStringField(TEXT("previousIkRig"), PrevRigPath);
	Result->SetBoolField(TEXT("unchanged"), PrevRig == IKRig);

	if (!PrevRigPath.IsEmpty())
	{
		TSharedPtr<FJsonObject> Rollback = MakeShared<FJsonObject>();
		Rollback->SetStringField(TEXT("retargeterPath"), Retargeter->GetPathName());
		Rollback->SetStringField(TEXT("rigPath"), PrevRigPath);
		Rollback->SetStringField(TEXT("side"), Side.ToLower());
		MCPSetRollback(Result, TEXT("set_ik_retargeter_rig"), Rollback);
#if UE_MCP_HAS_5_8_API
		// On 5.8 this action installs the default op stack when there is none and
		// runs CleanAsset. Reassigning the old rig does not uninstall those ops,
		// nor restore chain mappings CleanAsset dropped as unresolvable.
		Result->SetBoolField(TEXT("rollbackLossy"), true);
		Result->SetStringField(TEXT("rollbackNote"),
			TEXT("The replay puts the previous IK Rig back on this side. It does not remove a default retarget op stack this call installed, and chain mappings that CleanAsset dropped against the new rig are not restored: re-run animation(configure_ik_retargeter) with autoMapMode to rebuild them."));
#else
		Result->SetBoolField(TEXT("rollbackLossy"), false);
#endif
	}
	else
	{
		Result->SetBoolField(TEXT("rollbackPossible"), false);
		Result->SetStringField(TEXT("rollbackNote"),
			TEXT("This side carried no IK Rig before the call. set_ik_retargeter_rig requires a rigPath and cannot clear an assignment, so there is no call that returns the side to unassigned."));
	}
	return MCPResult(Result);
}

#if UE_MCP_HAS_5_8_API
// The retargeter's current pose on one side, in the shape configure_ik_retargeter
// accepts as its `pose` parameter. A whole-pose write is reversible only because
// that action selects the pose, resets it, and replays per-bone rotation offsets
// in that order, which is exactly a restore of what is captured here.
static TSharedPtr<FJsonObject> CaptureRetargetPosePayload(
	UIKRetargeterController* Controller,
	const ERetargetSourceOrTarget Side,
	const FString& SideName)
{
	const FName PoseName = Controller->GetCurrentRetargetPoseName(Side);
	TMap<FName, FIKRetargetPose>& Poses = Controller->GetRetargetPoses(Side);
	const FIKRetargetPose* Pose = Poses.Find(PoseName);
	if (!Pose) return nullptr;

	TSharedPtr<FJsonObject> PoseJson = MakeShared<FJsonObject>();
	PoseJson->SetStringField(TEXT("side"), SideName);
	PoseJson->SetStringField(TEXT("name"), PoseName.ToString());
	PoseJson->SetBoolField(TEXT("reset"), true);

	TArray<TSharedPtr<FJsonValue>> Offsets;
	for (const TPair<FName, FQuat>& Delta : Pose->GetAllDeltaRotations())
	{
		TSharedPtr<FJsonObject> Offset = MakeShared<FJsonObject>();
		Offset->SetStringField(TEXT("bone"), Delta.Key.ToString());
		// configure_ik_retargeter rejects a quaternion that is not normalized
		// within 1e-4, so normalize on the way out rather than on replay.
		Offset->SetObjectField(TEXT("rotationQuaternion"), AnimationQuaternionToJson(Delta.Value.GetNormalized()));
		Offsets.Add(MakeShared<FJsonValueObject>(Offset));
	}
	PoseJson->SetArrayField(TEXT("rotationOffsets"), Offsets);
	PoseJson->SetNumberField(TEXT("rootOffsetZ"), Pose->GetRootTranslationDelta().Z);
	return PoseJson;
}
#endif

// ─── #701 auto_align_retarget_pose ──────────────────────────────────
TSharedPtr<FJsonValue> FAnimationHandlers::AutoAlignRetargetPose(const TSharedPtr<FJsonObject>& Params)
{
	FString RetargeterPath;
	if (auto Err = RequireStringAlt(Params, TEXT("retargeterPath"), TEXT("assetPath"), RetargeterPath)) return Err;
	if (MCPIsProtectedAssetPath(RetargeterPath))
	{
		return MCPError(FString::Printf(TEXT("Protected asset cannot be modified: %s"), *RetargeterPath));
	}
	const FString Side = OptionalString(Params, TEXT("side"), TEXT("target"));
	// ParseSourceOrTarget maps anything that is not 'source' to Target, so an
	// unrecognised side used to succeed silently and then be echoed into a
	// rollback payload whose reader accepts only the two names. Refuse it here,
	// the way set_ik_retargeter_rig does.
	if (!Side.Equals(TEXT("source"), ESearchCase::IgnoreCase)
		&& !Side.Equals(TEXT("target"), ESearchCase::IgnoreCase))
	{
		return MCPError(TEXT("'side' must be 'source' or 'target'"));
	}

	UIKRetargeter* Retargeter = LoadObject<UIKRetargeter>(nullptr, *RetargeterPath);
	if (!Retargeter) return MCPError(FString::Printf(TEXT("IKRetargeter not found: %s"), *RetargeterPath));
	UIKRetargeterController* Controller = UIKRetargeterController::GetController(Retargeter);
	if (!Controller) return MCPError(TEXT("IKRetargeterController unavailable"));

	const ERetargetSourceOrTarget AlignSide = ParseSourceOrTarget(Side);
#if UE_MCP_HAS_5_8_API
	// Captured before the align overwrites every bone's delta rotation.
	TSharedPtr<FJsonObject> PrevPose = CaptureRetargetPosePayload(Controller, AlignSide, Side.ToLower());
#endif
	Controller->AutoAlignAllBones(AlignSide);
	SaveAssetPackage(Retargeter);

	auto Result = MCPSuccess();
	MCPSetUpdated(Result);
	Result->SetStringField(TEXT("retargeterPath"), Retargeter->GetPathName());
	Result->SetStringField(TEXT("side"), Side.ToLower());
	Result->SetStringField(TEXT("method"), TEXT("ChainToChain"));

#if UE_MCP_HAS_5_8_API
	if (PrevPose.IsValid())
	{
		TSharedPtr<FJsonObject> Rollback = MakeShared<FJsonObject>();
		Rollback->SetStringField(TEXT("retargeterPath"), Retargeter->GetPathName());
		// configure_ik_retargeter defaults ensureDefaultOps to TRUE, which would
		// make a pose restore install a default retarget op stack on a retargeter
		// that has none, or fail outright on a partial non-default stack. This
		// rollback is about the pose and nothing else, so it says so.
		Rollback->SetBoolField(TEXT("ensureDefaultOps"), false);
		Rollback->SetObjectField(TEXT("pose"), PrevPose);
		MCPSetRollback(Result, TEXT("configure_ik_retargeter"), Rollback);
		Result->SetBoolField(TEXT("rollbackLossy"), true);
		Result->SetStringField(TEXT("rollbackNote"),
			TEXT("The replay resets the pose and writes back every captured per-bone rotation offset plus the root offset's Z. ")
			TEXT("The root translation offset's X and Y are not restored, because configure_ik_retargeter authors rootOffsetZ only. ")
			TEXT("configure_ik_retargeter also runs CleanAsset on every call, which drops chain mappings it cannot resolve; rebuild them with the same action's autoMapMode if any go missing."));
	}
	else
	{
		Result->SetBoolField(TEXT("rollbackPossible"), false);
		Result->SetStringField(TEXT("rollbackNote"),
			TEXT("The retargeter has no retarget pose under the current pose name, so there were no offsets to capture and nothing for an inverse call to restore."));
	}
#else
	Result->SetBoolField(TEXT("rollbackPossible"), false);
	Result->SetStringField(TEXT("rollbackNote"),
		TEXT("Auto-alignment overwrites every bone's delta rotation in the retarget pose. Writing them back needs animation(configure_ik_retargeter), which requires Unreal Engine 5.8, ")
		TEXT("so on this engine there is no action that can replay them."));
#endif
	return MCPResult(Result);
}

// ─── #701 reset_retarget_pose ───────────────────────────────────────
TSharedPtr<FJsonValue> FAnimationHandlers::ResetRetargetPose(const TSharedPtr<FJsonObject>& Params)
{
	FString RetargeterPath;
	if (auto Err = RequireStringAlt(Params, TEXT("retargeterPath"), TEXT("assetPath"), RetargeterPath)) return Err;
	if (MCPIsProtectedAssetPath(RetargeterPath))
	{
		return MCPError(FString::Printf(TEXT("Protected asset cannot be modified: %s"), *RetargeterPath));
	}
	const FString Side = OptionalString(Params, TEXT("side"), TEXT("target"));
	// ParseSourceOrTarget maps anything that is not 'source' to Target, so an
	// unrecognised side used to succeed silently and then be echoed into a
	// rollback payload whose reader accepts only the two names. Refuse it here,
	// the way set_ik_retargeter_rig does.
	if (!Side.Equals(TEXT("source"), ESearchCase::IgnoreCase)
		&& !Side.Equals(TEXT("target"), ESearchCase::IgnoreCase))
	{
		return MCPError(TEXT("'side' must be 'source' or 'target'"));
	}

	UIKRetargeter* Retargeter = LoadObject<UIKRetargeter>(nullptr, *RetargeterPath);
	if (!Retargeter) return MCPError(FString::Printf(TEXT("IKRetargeter not found: %s"), *RetargeterPath));
	UIKRetargeterController* Controller = UIKRetargeterController::GetController(Retargeter);
	if (!Controller) return MCPError(TEXT("IKRetargeterController unavailable"));

	const ERetargetSourceOrTarget SoT = ParseSourceOrTarget(Side);
	const FName CurrentPose = Controller->GetCurrentRetargetPoseName(SoT);
#if UE_MCP_HAS_5_8_API
	// Captured before the reset clears every offset the pose held.
	TSharedPtr<FJsonObject> PrevPose = CaptureRetargetPosePayload(Controller, SoT, Side.ToLower());
#endif
	Controller->ResetRetargetPose(CurrentPose, TArray<FName>(), SoT);
	SaveAssetPackage(Retargeter);

	auto Result = MCPSuccess();
	MCPSetUpdated(Result);
	Result->SetStringField(TEXT("retargeterPath"), Retargeter->GetPathName());
	Result->SetStringField(TEXT("side"), Side.ToLower());
	Result->SetStringField(TEXT("pose"), CurrentPose.ToString());

#if UE_MCP_HAS_5_8_API
	if (PrevPose.IsValid())
	{
		TSharedPtr<FJsonObject> Rollback = MakeShared<FJsonObject>();
		Rollback->SetStringField(TEXT("retargeterPath"), Retargeter->GetPathName());
		// configure_ik_retargeter defaults ensureDefaultOps to TRUE, which would
		// make a pose restore install a default retarget op stack on a retargeter
		// that has none, or fail outright on a partial non-default stack. This
		// rollback is about the pose and nothing else, so it says so.
		Rollback->SetBoolField(TEXT("ensureDefaultOps"), false);
		Rollback->SetObjectField(TEXT("pose"), PrevPose);
		MCPSetRollback(Result, TEXT("configure_ik_retargeter"), Rollback);
		Result->SetBoolField(TEXT("rollbackLossy"), true);
		Result->SetStringField(TEXT("rollbackNote"),
			TEXT("The replay writes back every captured per-bone rotation offset plus the root offset's Z. ")
			TEXT("The root translation offset's X and Y are not restored, because configure_ik_retargeter authors rootOffsetZ only. ")
			TEXT("configure_ik_retargeter also runs CleanAsset on every call, which drops chain mappings it cannot resolve; rebuild them with the same action's autoMapMode if any go missing."));
	}
	else
	{
		Result->SetBoolField(TEXT("rollbackPossible"), false);
		Result->SetStringField(TEXT("rollbackNote"),
			TEXT("The retargeter has no retarget pose under the current pose name, so there were no offsets to capture and nothing for an inverse call to restore."));
	}
#else
	Result->SetBoolField(TEXT("rollbackPossible"), false);
	Result->SetStringField(TEXT("rollbackNote"),
		TEXT("The reset clears every bone offset the pose held. Writing them back needs animation(configure_ik_retargeter), which requires Unreal Engine 5.8, ")
		TEXT("so on this engine there is no action that can replay them."));
#endif
	return MCPResult(Result);
}

// ─── #701 batch_retarget_animations ─────────────────────────────────
TSharedPtr<FJsonValue> FAnimationHandlers::BatchRetargetAnimations(const TSharedPtr<FJsonObject>& Params)
{
#if !(ENGINE_MAJOR_VERSION > 5 || (ENGINE_MAJOR_VERSION == 5 && ENGINE_MINOR_VERSION >= 8))
	auto Result = MakeShared<FJsonObject>();
	Result->SetBoolField(TEXT("success"), false);
	Result->SetStringField(TEXT("errorCode"), TEXT("unsupported_engine_version"));
	Result->SetStringField(TEXT("error"), TEXT("batch_retarget_animations requires Unreal Engine 5.8 or newer"));
	return MCPResult(Result);
#else
	FString RetargeterPath;
	if (auto Err = RequireStringAlt(Params, TEXT("retargeterPath"), TEXT("assetPath"), RetargeterPath)) return Err;
	FString SourceMeshPath, TargetMeshPath;
	if (auto Err = RequireString(Params, TEXT("sourceMesh"), SourceMeshPath)) return Err;
	if (auto Err = RequireString(Params, TEXT("targetMesh"), TargetMeshPath)) return Err;

	UIKRetargeter* Retargeter = LoadObject<UIKRetargeter>(nullptr, *RetargeterPath);
	if (!Retargeter) return MCPError(FString::Printf(TEXT("IKRetargeter not found: %s"), *RetargeterPath));
	USkeletalMesh* SourceMesh = LoadObject<USkeletalMesh>(nullptr, *SourceMeshPath);
	if (!SourceMesh) return MCPError(FString::Printf(TEXT("Source mesh not found: %s"), *SourceMeshPath));
	USkeletalMesh* TargetMesh = LoadObject<USkeletalMesh>(nullptr, *TargetMeshPath);
	if (!TargetMesh) return MCPError(FString::Printf(TEXT("Target mesh not found: %s"), *TargetMeshPath));
	if (SourceMesh == TargetMesh) return MCPError(TEXT("sourceMesh and targetMesh must be different"));
	if (OptionalBool(Params, TEXT("overwrite"), false))
	{
		return MCPError(TEXT("overwrite=true is not supported; choose a new output name/path so existing assets are never replaced"));
	}

	bool bMappingInspectionAvailable = false;
	int32 TargetChainCount = 0;
	int32 MappedChainCount = 0;
	TArray<TSharedPtr<FJsonValue>> UnmappedTargetChains;
	if (UIKRetargeterController* Controller = UIKRetargeterController::GetController(Retargeter))
	{
		if (const UIKRigDefinition* TargetRig = Controller->GetIKRig(ERetargetSourceOrTarget::Target))
		{
			bMappingInspectionAvailable = true;
			for (const FBoneChain& TargetChain : TargetRig->GetRetargetChains())
			{
				++TargetChainCount;
				if (Controller->GetSourceChain(TargetChain.ChainName).IsNone())
				{
					UnmappedTargetChains.Add(MakeShared<FJsonValueString>(TargetChain.ChainName.ToString()));
				}
				else
				{
					++MappedChainCount;
				}
			}
		}
	}
	const bool bRequireCompleteMapping = OptionalBool(Params, TEXT("requireCompleteMapping"), false);
	if (bRequireCompleteMapping && !bMappingInspectionAvailable)
	{
		return MCPError(TEXT("Cannot verify complete mapping because the retargeter has no target IK Rig"));
	}
	if (bRequireCompleteMapping && !UnmappedTargetChains.IsEmpty())
	{
		return MCPError(FString::Printf(
			TEXT("Retargeter has %d unmapped target chain(s) and requireCompleteMapping=true"),
			UnmappedTargetChains.Num()));
	}

	const TArray<TSharedPtr<FJsonValue>>* AnimArr = nullptr;
	if (!Params->TryGetArrayField(TEXT("animPaths"), AnimArr) || !AnimArr || AnimArr->Num() == 0)
	{
		return MCPError(TEXT("Missing 'animPaths' (array of AnimSequence paths to retarget)"));
	}

	FIKRetargetBatchOperationInputs Inputs;
	Inputs.SourceMesh = SourceMesh;
	Inputs.TargetMesh = TargetMesh;
	Inputs.IKRetargetAsset = Retargeter;
	Inputs.bOverwriteExistingFiles = false;
	Inputs.bIncludeReferencedAssets = false;
	Inputs.Prefix = OptionalString(Params, TEXT("prefix"));
	Inputs.Suffix = OptionalString(Params, TEXT("suffix"), TEXT("_Retargeted"));
	const FString TargetPath = OptionalString(Params, TEXT("outputPath"));
	if (!TargetPath.IsEmpty() && MCPIsProtectedAssetPath(TargetPath))
	{
		return MCPError(FString::Printf(TEXT("Protected output path is not allowed: %s"), *TargetPath));
	}
	if (TargetPath.IsEmpty()) { Inputs.bUseSourcePath = true; }
	else { Inputs.TargetPath = TargetPath; }

	TSet<FString> SeenPaths;
	int32 Loaded = 0;
	for (int32 Index = 0; Index < AnimArr->Num(); ++Index)
	{
		const TSharedPtr<FJsonValue>& V = (*AnimArr)[Index];
		FString P;
		if (!V.IsValid() || !V->TryGetString(P) || P.IsEmpty())
		{
			return MCPError(FString::Printf(TEXT("animPaths[%d] must be a non-empty AnimSequence asset path"), Index));
		}
		if (SeenPaths.Contains(P))
		{
			return MCPError(FString::Printf(TEXT("Duplicate animPaths entry: %s"), *P));
		}
		if (TargetPath.IsEmpty() && MCPIsProtectedAssetPath(P))
		{
			return MCPError(FString::Printf(TEXT("Cannot create a retargeted asset beside protected source path: %s"), *P));
		}
		UAnimSequence* Anim = LoadObject<UAnimSequence>(nullptr, *P);
		if (!Anim)
		{
			return MCPError(FString::Printf(TEXT("AnimSequence not found: %s"), *P));
		}
		if (!SourceMesh->GetSkeleton() || !Anim->GetSkeleton()
			|| !SourceMesh->GetSkeleton()->IsCompatibleForEditor(Anim->GetSkeleton()))
		{
			return MCPError(FString::Printf(
				TEXT("AnimSequence skeleton is incompatible with sourceMesh: %s"), *P));
		}
		SeenPaths.Add(P);
		Inputs.AssetsToRetarget.Add(FAssetData(Anim));
		++Loaded;
	}

	FScopedBatchRetargetEditorInstanceRestore RestoreEditorInstances(Retargeter);
	FIKRetargetProcessor ValidationProcessor;
	FRetargetInitParameters ValidationParameters;
	ValidationParameters.SourceSkeletalMesh = SourceMesh;
	ValidationParameters.TargetSkeletalMesh = TargetMesh;
	ValidationParameters.RetargeterAsset = Retargeter;
	ValidationParameters.bSuppressWarnings = false;
	ValidationProcessor.Initialize(ValidationParameters);
	const TArray<FText> ValidationErrors = ValidationProcessor.Log.GetErrors();
	if (!ValidationProcessor.IsInitialized() || !ValidationErrors.IsEmpty())
	{
		return MCPError(!ValidationErrors.IsEmpty()
			? FString::Printf(TEXT("IK Retargeter processor validation failed: %s"), *ValidationErrors[0].ToString())
			: TEXT("IK Retargeter processor validation failed to initialize"));
	}

	const TArray<FAssetData> Created = UIKRetargetBatchOperation::RunBatchRetarget(Inputs);
	auto DeleteCreatedAssets = [&Created]()
	{
		TArray<FString> ResidualPaths;
		for (const FAssetData& AssetData : Created)
		{
			const FString ObjectPath = AssetData.GetObjectPathString();
			if (ObjectPath.IsEmpty())
			{
				ResidualPaths.Add(TEXT("<unknown output path>"));
				continue;
			}
			UEditorAssetLibrary::DeleteAsset(ObjectPath);
			if (UEditorAssetLibrary::DoesAssetExist(ObjectPath)) ResidualPaths.Add(ObjectPath);
		}
		return ResidualPaths;
	};
	if (Created.Num() != Loaded)
	{
		const TArray<FString> ResidualPaths = DeleteCreatedAssets();
		if (!ResidualPaths.IsEmpty())
		{
			return MCPError(FString::Printf(
				TEXT("Batch retarget created %d of %d requested assets and cleanup failed for: %s"),
				Created.Num(), Loaded, *FString::Join(ResidualPaths, TEXT(", "))));
		}
		return MCPError(FString::Printf(
			TEXT("Batch retarget created %d of %d requested assets; all new outputs were deleted"),
			Created.Num(), Loaded));
	}

	TArray<TSharedPtr<FJsonValue>> OutPaths;
	for (const FAssetData& AD : Created)
	{
		UObject* CreatedAsset = AD.GetAsset();
		if (!CreatedAsset || !SaveAssetPackage(CreatedAsset))
		{
			const FString FailedPath = AD.GetObjectPathString();
			const TArray<FString> ResidualPaths = DeleteCreatedAssets();
			if (!ResidualPaths.IsEmpty())
			{
				return MCPError(FString::Printf(
					TEXT("Failed to save retargeted asset '%s' and cleanup failed for: %s"),
					*FailedPath, *FString::Join(ResidualPaths, TEXT(", "))));
			}
			return MCPError(FString::Printf(
				TEXT("Failed to save retargeted asset '%s'; all new outputs were deleted"),
				*FailedPath));
		}
		OutPaths.Add(MakeShared<FJsonValueString>(AD.GetObjectPathString()));
	}

	auto Result = MCPSuccess();
	MCPSetCreated(Result);
	Result->SetStringField(TEXT("retargeterPath"), Retargeter->GetPathName());
	Result->SetNumberField(TEXT("requested"), Loaded);
	Result->SetNumberField(TEXT("createdCount"), OutPaths.Num());
	Result->SetArrayField(TEXT("createdAssets"), OutPaths);
	Result->SetBoolField(TEXT("includedReferencedAssets"), false);
	Result->SetBoolField(TEXT("requireCompleteMapping"), bRequireCompleteMapping);
	Result->SetBoolField(TEXT("mappingInspectionAvailable"), bMappingInspectionAvailable);
	if (bMappingInspectionAvailable)
	{
		Result->SetNumberField(TEXT("targetChainCount"), TargetChainCount);
		Result->SetNumberField(TEXT("mappedChainCount"), MappedChainCount);
		Result->SetArrayField(TEXT("unmappedTargetChains"), UnmappedTargetChains);
		Result->SetBoolField(TEXT("mappingComplete"), UnmappedTargetChains.IsEmpty());
		if (!UnmappedTargetChains.IsEmpty())
		{
			Result->SetStringField(TEXT("mappingWarning"), FString::Printf(
				TEXT("Retarget completed with %d unmapped target chain(s); inspect deformation and compare sampled poses before accepting the output"),
				UnmappedTargetChains.Num()));
		}
	}
	TSharedPtr<FJsonObject> RollbackPayload = MakeShared<FJsonObject>();
	RollbackPayload->SetArrayField(TEXT("assetPaths"), OutPaths);
	MCPSetRollback(Result, TEXT("delete_asset_batch"), RollbackPayload);
	return MCPResult(Result);
#endif
}

// ─── #657 inspect_anim_nodes ────────────────────────────────────────
// Deep-dump the FAnimNode_* struct on anim graph nodes. read_anim_graph skips
// properties starting with "Node", which is exactly where the anim node data
// lives - so a PoseDriver's PoseTargets/PoseAsset/RBFParams/source bones were
// invisible. Optional nodeClass substring filters (e.g. "PoseDriver").
TSharedPtr<FJsonValue> FAnimationHandlers::InspectAnimNodes(const TSharedPtr<FJsonObject>& Params)
{
	FString AssetPath;
	if (auto Err = RequireStringAlt(Params, TEXT("assetPath"), TEXT("path"), AssetPath)) return Err;
	const FString GraphName = OptionalString(Params, TEXT("graphName"), TEXT("AnimGraph"));
	const FString ClassFilter = OptionalString(Params, TEXT("nodeClass"));

	UAnimBlueprint* AnimBP = LoadAnimBP(AssetPath);
	if (!AnimBP) return MCPError(FString::Printf(TEXT("AnimBlueprint not found: %s"), *AssetPath));
	UEdGraph* TargetGraph = FindGraphByName(AnimBP, GraphName);
	if (!TargetGraph) return MCPError(FString::Printf(TEXT("Graph not found: %s"), *GraphName));

	// Recursively export a struct's editable sub-properties as name/type/value.
	TFunction<TSharedPtr<FJsonObject>(const UStruct*, const void*, int32)> DumpStruct;
	DumpStruct = [&DumpStruct](const UStruct* Struct, const void* Container, int32 Depth) -> TSharedPtr<FJsonObject>
	{
		TSharedPtr<FJsonObject> Obj = MakeShared<FJsonObject>();
		if (!Struct || !Container) return Obj;
		for (TFieldIterator<FProperty> It(Struct, EFieldIteratorFlags::IncludeSuper); It; ++It)
		{
			FProperty* Prop = *It;
			if (!Prop) continue;
			const void* ValuePtr = Prop->ContainerPtrToValuePtr<void>(Container);
			const FString PName = Prop->GetName();

			// Recurse one level into nested structs (RBFParams, etc.) for readability.
			if (Depth < 2)
			{
				if (const FStructProperty* SubStruct = CastField<FStructProperty>(Prop))
				{
					Obj->SetObjectField(PName, DumpStruct(SubStruct->Struct, ValuePtr, Depth + 1));
					continue;
				}
			}
			FString ValueStr;
			Prop->ExportTextItem_Direct(ValueStr, ValuePtr, nullptr, nullptr, PPF_None);
			Obj->SetStringField(PName, ValueStr);
		}
		return Obj;
	};

	TArray<TSharedPtr<FJsonValue>> NodesArray;
	for (UEdGraphNode* Node : TargetGraph->Nodes)
	{
		if (!Node) continue;
		const FString NodeClassName = Node->GetClass()->GetName();
		if (!ClassFilter.IsEmpty() && !NodeClassName.Contains(ClassFilter)) continue;

		// Find the FAnimNode_* struct member (its type derives from FAnimNode_Base).
		FStructProperty* AnimNodeProp = nullptr;
		for (TFieldIterator<FProperty> It(Node->GetClass(), EFieldIteratorFlags::IncludeSuper); It; ++It)
		{
			if (FStructProperty* SP = CastField<FStructProperty>(*It))
			{
				if (SP->Struct && SP->Struct->GetName().StartsWith(TEXT("AnimNode_")))
				{
					AnimNodeProp = SP;
					break;
				}
			}
		}
		if (ClassFilter.IsEmpty() && !AnimNodeProp) continue; // only anim nodes when unfiltered

		TSharedPtr<FJsonObject> NodeObj = MakeShared<FJsonObject>();
		NodeObj->SetStringField(TEXT("id"), Node->NodeGuid.ToString());
		NodeObj->SetStringField(TEXT("class"), NodeClassName);
		NodeObj->SetStringField(TEXT("title"), Node->GetNodeTitle(ENodeTitleType::FullTitle).ToString());
		if (AnimNodeProp)
		{
			NodeObj->SetStringField(TEXT("animNodeStruct"), AnimNodeProp->Struct->GetName());
			NodeObj->SetObjectField(TEXT("node"), DumpStruct(AnimNodeProp->Struct, AnimNodeProp->ContainerPtrToValuePtr<void>(Node), 0));
		}
		NodesArray.Add(MakeShared<FJsonValueObject>(NodeObj));
	}

	auto Result = MCPSuccess();
	Result->SetStringField(TEXT("assetPath"), AssetPath);
	Result->SetStringField(TEXT("graphName"), GraphName);
	Result->SetNumberField(TEXT("count"), NodesArray.Num());
	Result->SetArrayField(TEXT("nodes"), NodesArray);
	return MCPResult(Result);
}
