ue-mcp 0.7.9 → 0.7.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (19) hide show
  1. package/package.json +1 -1
  2. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AnimationHandlers.cpp +208 -26
  3. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AssetHandlers.cpp +9 -0
  4. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AudioHandlers.cpp +3 -1
  5. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/BlueprintHandlers.cpp +284 -27
  6. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/DialogHandlers.cpp +20 -0
  7. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/EditorHandlers.cpp +46 -0
  8. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/FoliageHandlers.cpp +10 -0
  9. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/GameplayHandlers.cpp +192 -1
  10. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/GasHandlers.cpp +62 -1
  11. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/LandscapeHandlers.cpp +22 -0
  12. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/MaterialHandlers.cpp +221 -16
  13. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/NetworkingHandlers.cpp +177 -41
  14. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/NiagaraHandlers.cpp +45 -1
  15. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/PCGHandlers.cpp +18 -2
  16. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/PhysicsHandlers.cpp +145 -15
  17. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/SequencerHandlers.cpp +28 -0
  18. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/SplineHandlers.cpp +20 -0
  19. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/WidgetHandlers.cpp +60 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ue-mcp",
3
- "version": "0.7.9",
3
+ "version": "0.7.10",
4
4
  "description": "Unreal Engine MCP server — 19 tools, 300+ actions for AI-driven editor control",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -769,6 +769,21 @@ TSharedPtr<FJsonValue> FAnimationHandlers::AddAnimNotify(const TSharedPtr<FJsonO
769
769
  float PlayLength = AnimAsset->GetPlayLength();
770
770
  float ClampedTime = FMath::Clamp(static_cast<float>(TriggerTime), 0.0f, PlayLength);
771
771
 
772
+ // Idempotency: check for existing notify with same name at same trigger time
773
+ const FName NotifyFName(*NotifyName);
774
+ for (const FAnimNotifyEvent& Existing : AnimAsset->Notifies)
775
+ {
776
+ if (Existing.NotifyName == NotifyFName && FMath::IsNearlyEqual(Existing.GetTime(), ClampedTime, 0.001f))
777
+ {
778
+ auto ExistedRes = MCPSuccess();
779
+ MCPSetExisted(ExistedRes);
780
+ ExistedRes->SetStringField(TEXT("assetPath"), AssetPath);
781
+ ExistedRes->SetStringField(TEXT("notifyName"), NotifyName);
782
+ ExistedRes->SetNumberField(TEXT("triggerTime"), ClampedTime);
783
+ return MCPResult(ExistedRes);
784
+ }
785
+ }
786
+
772
787
  // If a notify class is specified, try to find and instantiate it
773
788
  UAnimNotify* NewNotify = nullptr;
774
789
  if (!NotifyClassName.IsEmpty())
@@ -805,6 +820,7 @@ TSharedPtr<FJsonValue> FAnimationHandlers::AddAnimNotify(const TSharedPtr<FJsonO
805
820
  UEditorAssetLibrary::SaveAsset(AssetPath);
806
821
 
807
822
  auto Result = MCPSuccess();
823
+ MCPSetCreated(Result);
808
824
  Result->SetStringField(TEXT("assetPath"), AssetPath);
809
825
  Result->SetStringField(TEXT("notifyName"), NotifyName);
810
826
  Result->SetNumberField(TEXT("triggerTime"), ClampedTime);
@@ -812,6 +828,7 @@ TSharedPtr<FJsonValue> FAnimationHandlers::AddAnimNotify(const TSharedPtr<FJsonO
812
828
  {
813
829
  Result->SetStringField(TEXT("notifyClass"), NewNotify->GetClass()->GetName());
814
830
  }
831
+ // No rollback: no paired remove_anim_notify handler yet.
815
832
 
816
833
  return MCPResult(Result);
817
834
  }
@@ -1341,58 +1358,96 @@ TSharedPtr<FJsonValue> FAnimationHandlers::SetMontageProperties(const TSharedPtr
1341
1358
  return MCPError(FString::Printf(TEXT("Failed to load AnimMontage at '%s'"), *AssetPath));
1342
1359
  }
1343
1360
 
1361
+ // Capture previous values for rollback
1362
+ const float PrevSeqLen = Montage->GetPlayLength();
1363
+ const float PrevRateScale = Montage->RateScale;
1364
+ const float PrevBlendIn = Montage->BlendIn.GetBlendTime();
1365
+ const float PrevBlendOut = Montage->BlendOut.GetBlendTime();
1366
+
1344
1367
  TArray<FString> Modified;
1368
+ bool bAnyChanged = false;
1345
1369
 
1346
1370
  // sequenceLength — update via property reflection (SequenceLength is protected)
1347
1371
  double SeqLen;
1348
- if (Params->TryGetNumberField(TEXT("sequenceLength"), SeqLen))
1372
+ const bool bHasSeqLen = Params->TryGetNumberField(TEXT("sequenceLength"), SeqLen);
1373
+ if (bHasSeqLen)
1349
1374
  {
1350
1375
  float NewLength = static_cast<float>(SeqLen);
1351
- SetMontageSequenceLength(Montage, NewLength);
1352
-
1353
- // Also update composite sections' segment lengths to match
1354
- for (FCompositeSection& Section : Montage->CompositeSections)
1376
+ if (!FMath::IsNearlyEqual(NewLength, PrevSeqLen))
1355
1377
  {
1356
- SetSegmentLength(Section, NewLength);
1378
+ SetMontageSequenceLength(Montage, NewLength);
1379
+ for (FCompositeSection& Section : Montage->CompositeSections)
1380
+ {
1381
+ SetSegmentLength(Section, NewLength);
1382
+ }
1383
+ Modified.Add(TEXT("sequenceLength"));
1384
+ bAnyChanged = true;
1357
1385
  }
1358
- Modified.Add(TEXT("sequenceLength"));
1359
1386
  }
1360
1387
 
1361
1388
  // rateScale
1362
1389
  double RateScale;
1363
- if (Params->TryGetNumberField(TEXT("rateScale"), RateScale))
1390
+ const bool bHasRate = Params->TryGetNumberField(TEXT("rateScale"), RateScale);
1391
+ if (bHasRate)
1364
1392
  {
1365
- Montage->RateScale = static_cast<float>(RateScale);
1366
- Modified.Add(TEXT("rateScale"));
1393
+ float NewRate = static_cast<float>(RateScale);
1394
+ if (!FMath::IsNearlyEqual(NewRate, PrevRateScale))
1395
+ {
1396
+ Montage->RateScale = NewRate;
1397
+ Modified.Add(TEXT("rateScale"));
1398
+ bAnyChanged = true;
1399
+ }
1367
1400
  }
1368
1401
 
1369
1402
  // blendIn
1370
1403
  double BlendIn;
1371
- if (Params->TryGetNumberField(TEXT("blendIn"), BlendIn))
1404
+ const bool bHasBlendIn = Params->TryGetNumberField(TEXT("blendIn"), BlendIn);
1405
+ if (bHasBlendIn)
1372
1406
  {
1373
- Montage->BlendIn.SetBlendTime(static_cast<float>(BlendIn));
1374
- Modified.Add(TEXT("blendIn"));
1407
+ float NewIn = static_cast<float>(BlendIn);
1408
+ if (!FMath::IsNearlyEqual(NewIn, PrevBlendIn))
1409
+ {
1410
+ Montage->BlendIn.SetBlendTime(NewIn);
1411
+ Modified.Add(TEXT("blendIn"));
1412
+ bAnyChanged = true;
1413
+ }
1375
1414
  }
1376
1415
 
1377
1416
  // blendOut
1378
1417
  double BlendOut;
1379
- if (Params->TryGetNumberField(TEXT("blendOut"), BlendOut))
1418
+ const bool bHasBlendOut = Params->TryGetNumberField(TEXT("blendOut"), BlendOut);
1419
+ if (bHasBlendOut)
1380
1420
  {
1381
- Montage->BlendOut.SetBlendTime(static_cast<float>(BlendOut));
1382
- Modified.Add(TEXT("blendOut"));
1421
+ float NewOut = static_cast<float>(BlendOut);
1422
+ if (!FMath::IsNearlyEqual(NewOut, PrevBlendOut))
1423
+ {
1424
+ Montage->BlendOut.SetBlendTime(NewOut);
1425
+ Modified.Add(TEXT("blendOut"));
1426
+ bAnyChanged = true;
1427
+ }
1383
1428
  }
1384
1429
 
1385
- if (Modified.Num() == 0)
1430
+ if (!bHasSeqLen && !bHasRate && !bHasBlendIn && !bHasBlendOut)
1386
1431
  {
1387
1432
  return MCPError(TEXT("No properties to set. Provide at least one of: sequenceLength, rateScale, blendIn, blendOut"));
1388
1433
  }
1389
1434
 
1435
+ // Idempotent: requested values match current state
1436
+ if (!bAnyChanged)
1437
+ {
1438
+ auto Noop = MCPSuccess();
1439
+ MCPSetExisted(Noop);
1440
+ Noop->SetStringField(TEXT("assetPath"), AssetPath);
1441
+ return MCPResult(Noop);
1442
+ }
1443
+
1390
1444
  Montage->PostEditChange();
1391
1445
  Montage->MarkPackageDirty();
1392
1446
  UEditorAssetLibrary::SaveAsset(AssetPath);
1393
1447
 
1394
1448
  // Return current state
1395
1449
  auto Result = MCPSuccess();
1450
+ MCPSetUpdated(Result);
1396
1451
  Result->SetStringField(TEXT("assetPath"), AssetPath);
1397
1452
  TArray<TSharedPtr<FJsonValue>> ModifiedArray;
1398
1453
  for (const FString& M : Modified)
@@ -1405,6 +1460,15 @@ TSharedPtr<FJsonValue> FAnimationHandlers::SetMontageProperties(const TSharedPtr
1405
1460
  Result->SetNumberField(TEXT("blendIn"), Montage->BlendIn.GetBlendTime());
1406
1461
  Result->SetNumberField(TEXT("blendOut"), Montage->BlendOut.GetBlendTime());
1407
1462
 
1463
+ // Rollback: self-inverse with previous values
1464
+ TSharedPtr<FJsonObject> Payload = MakeShared<FJsonObject>();
1465
+ Payload->SetStringField(TEXT("assetPath"), AssetPath);
1466
+ if (bHasSeqLen) Payload->SetNumberField(TEXT("sequenceLength"), PrevSeqLen);
1467
+ if (bHasRate) Payload->SetNumberField(TEXT("rateScale"), PrevRateScale);
1468
+ if (bHasBlendIn) Payload->SetNumberField(TEXT("blendIn"), PrevBlendIn);
1469
+ if (bHasBlendOut) Payload->SetNumberField(TEXT("blendOut"), PrevBlendOut);
1470
+ MCPSetRollback(Result, TEXT("set_montage_properties"), Payload);
1471
+
1408
1472
  return MCPResult(Result);
1409
1473
  }
1410
1474
 
@@ -1502,6 +1566,17 @@ TSharedPtr<FJsonValue> FAnimationHandlers::CreateStateMachine(const TSharedPtr<F
1502
1566
  return MCPError(FString::Printf(TEXT("Graph not found: %s"), *GraphName));
1503
1567
  }
1504
1568
 
1569
+ // Idempotency: check for existing state machine by name
1570
+ if (FindStateMachineNode(AnimBP, Name))
1571
+ {
1572
+ auto Existed = MCPSuccess();
1573
+ MCPSetExisted(Existed);
1574
+ Existed->SetStringField(TEXT("assetPath"), AssetPath);
1575
+ Existed->SetStringField(TEXT("name"), Name);
1576
+ Existed->SetStringField(TEXT("graphName"), GraphName);
1577
+ return MCPResult(Existed);
1578
+ }
1579
+
1505
1580
  // Create the state machine container node in the AnimGraph
1506
1581
  UAnimGraphNode_StateMachine* SMNode = NewObject<UAnimGraphNode_StateMachine>(TargetGraph);
1507
1582
  TargetGraph->AddNode(SMNode, false, false);
@@ -1520,9 +1595,11 @@ TSharedPtr<FJsonValue> FAnimationHandlers::CreateStateMachine(const TSharedPtr<F
1520
1595
  CompileAndSave(AnimBP);
1521
1596
 
1522
1597
  auto Result = MCPSuccess();
1598
+ MCPSetCreated(Result);
1523
1599
  Result->SetStringField(TEXT("assetPath"), AssetPath);
1524
1600
  Result->SetStringField(TEXT("name"), Name);
1525
1601
  Result->SetStringField(TEXT("graphName"), GraphName);
1602
+ // No rollback: no paired remove_state_machine handler.
1526
1603
 
1527
1604
  return MCPResult(Result);
1528
1605
  }
@@ -1556,10 +1633,20 @@ TSharedPtr<FJsonValue> FAnimationHandlers::AddState(const TSharedPtr<FJsonObject
1556
1633
  return MCPError(TEXT("State machine has no editor graph"));
1557
1634
  }
1558
1635
 
1559
- // Check for duplicate
1636
+ // Idempotency: existing state with this name short-circuits
1637
+ const FString OnConflict = OptionalString(Params, TEXT("onConflict"), TEXT("skip"));
1560
1638
  if (FindStateNode(SMGraph, StateName))
1561
1639
  {
1562
- return MCPError(FString::Printf(TEXT("State '%s' already exists"), *StateName));
1640
+ if (OnConflict == TEXT("error"))
1641
+ {
1642
+ return MCPError(FString::Printf(TEXT("State '%s' already exists"), *StateName));
1643
+ }
1644
+ auto Existed = MCPSuccess();
1645
+ MCPSetExisted(Existed);
1646
+ Existed->SetStringField(TEXT("assetPath"), AssetPath);
1647
+ Existed->SetStringField(TEXT("stateMachineName"), SMName);
1648
+ Existed->SetStringField(TEXT("stateName"), StateName);
1649
+ return MCPResult(Existed);
1563
1650
  }
1564
1651
 
1565
1652
  // Create state node
@@ -1584,9 +1671,11 @@ TSharedPtr<FJsonValue> FAnimationHandlers::AddState(const TSharedPtr<FJsonObject
1584
1671
  CompileAndSave(AnimBP);
1585
1672
 
1586
1673
  auto Result = MCPSuccess();
1674
+ MCPSetCreated(Result);
1587
1675
  Result->SetStringField(TEXT("assetPath"), AssetPath);
1588
1676
  Result->SetStringField(TEXT("stateMachineName"), SMName);
1589
1677
  Result->SetStringField(TEXT("stateName"), StateName);
1678
+ // No rollback: no paired remove_state handler.
1590
1679
 
1591
1680
  return MCPResult(Result);
1592
1681
  }
@@ -1629,6 +1718,33 @@ TSharedPtr<FJsonValue> FAnimationHandlers::AddTransition(const TSharedPtr<FJsonO
1629
1718
  return MCPError(FString::Printf(TEXT("State '%s' not found"), *ToState));
1630
1719
  }
1631
1720
 
1721
+ // Idempotency: check if a transition From→To already exists
1722
+ UEdGraphPin* FromOutPin = From->GetOutputPin();
1723
+ if (FromOutPin)
1724
+ {
1725
+ for (UEdGraphPin* Linked : FromOutPin->LinkedTo)
1726
+ {
1727
+ if (!Linked || !Linked->GetOwningNode()) continue;
1728
+ UAnimStateTransitionNode* ExistingTrans = Cast<UAnimStateTransitionNode>(Linked->GetOwningNode());
1729
+ if (!ExistingTrans) continue;
1730
+ UEdGraphPin* ExistingTransOut = ExistingTrans->GetOutputPin();
1731
+ if (!ExistingTransOut) continue;
1732
+ for (UEdGraphPin* ToLinked : ExistingTransOut->LinkedTo)
1733
+ {
1734
+ if (ToLinked && ToLinked->GetOwningNode() == To)
1735
+ {
1736
+ auto ExistedRes = MCPSuccess();
1737
+ MCPSetExisted(ExistedRes);
1738
+ ExistedRes->SetStringField(TEXT("assetPath"), AssetPath);
1739
+ ExistedRes->SetStringField(TEXT("stateMachineName"), SMName);
1740
+ ExistedRes->SetStringField(TEXT("fromState"), FromState);
1741
+ ExistedRes->SetStringField(TEXT("toState"), ToState);
1742
+ return MCPResult(ExistedRes);
1743
+ }
1744
+ }
1745
+ }
1746
+ }
1747
+
1632
1748
  // Create transition node
1633
1749
  UAnimStateTransitionNode* TransNode = NewObject<UAnimStateTransitionNode>(SMGraph);
1634
1750
  SMGraph->AddNode(TransNode, false, false);
@@ -1658,10 +1774,12 @@ TSharedPtr<FJsonValue> FAnimationHandlers::AddTransition(const TSharedPtr<FJsonO
1658
1774
  CompileAndSave(AnimBP);
1659
1775
 
1660
1776
  auto Result = MCPSuccess();
1777
+ MCPSetCreated(Result);
1661
1778
  Result->SetStringField(TEXT("assetPath"), AssetPath);
1662
1779
  Result->SetStringField(TEXT("stateMachineName"), SMName);
1663
1780
  Result->SetStringField(TEXT("fromState"), FromState);
1664
1781
  Result->SetStringField(TEXT("toState"), ToState);
1782
+ // No rollback: no paired remove_transition handler.
1665
1783
 
1666
1784
  return MCPResult(Result);
1667
1785
  }
@@ -2053,15 +2171,21 @@ TSharedPtr<FJsonValue> FAnimationHandlers::AddCurve(const TSharedPtr<FJsonObject
2053
2171
 
2054
2172
  if (!bAdded)
2055
2173
  {
2056
- // Curve may already exist not necessarily an error
2057
- Result->SetStringField(TEXT("warning"), FString::Printf(TEXT("Curve '%s' may already exist"), *CurveName));
2174
+ // Curve already exists idempotent replay
2175
+ MCPSetExisted(Result);
2176
+ Result->SetStringField(TEXT("assetPath"), AssetPath);
2177
+ Result->SetStringField(TEXT("curveName"), CurveName);
2178
+ return MCPResult(Result);
2058
2179
  }
2059
2180
 
2181
+ MCPSetCreated(Result);
2182
+
2060
2183
  AnimSeq->MarkPackageDirty();
2061
2184
  UEditorAssetLibrary::SaveAsset(AssetPath);
2062
2185
 
2063
2186
  Result->SetStringField(TEXT("assetPath"), AssetPath);
2064
2187
  Result->SetStringField(TEXT("curveName"), CurveName);
2188
+ // No rollback: no paired remove_curve handler.
2065
2189
 
2066
2190
  return MCPResult(Result);
2067
2191
  }
@@ -2090,16 +2214,37 @@ TSharedPtr<FJsonValue> FAnimationHandlers::SetMontageSlot(const TSharedPtr<FJson
2090
2214
  return MCPError(FString::Printf(TEXT("trackIndex %d out of range (0..%d)"), TrackIndex, Montage->SlotAnimTracks.Num() - 1));
2091
2215
  }
2092
2216
 
2093
- Montage->SlotAnimTracks[TrackIndex].SlotName = FName(*SlotName);
2217
+ // Capture previous slot name for rollback and idempotency
2218
+ const FName PrevSlot = Montage->SlotAnimTracks[TrackIndex].SlotName;
2219
+ const FName NewSlotFName(*SlotName);
2220
+ if (PrevSlot == NewSlotFName)
2221
+ {
2222
+ auto Noop = MCPSuccess();
2223
+ MCPSetExisted(Noop);
2224
+ Noop->SetStringField(TEXT("assetPath"), AssetPath);
2225
+ Noop->SetStringField(TEXT("slotName"), SlotName);
2226
+ Noop->SetNumberField(TEXT("trackIndex"), TrackIndex);
2227
+ return MCPResult(Noop);
2228
+ }
2229
+
2230
+ Montage->SlotAnimTracks[TrackIndex].SlotName = NewSlotFName;
2094
2231
 
2095
2232
  Montage->MarkPackageDirty();
2096
2233
  UEditorAssetLibrary::SaveAsset(AssetPath);
2097
2234
 
2098
2235
  auto Result = MCPSuccess();
2236
+ MCPSetUpdated(Result);
2099
2237
  Result->SetStringField(TEXT("assetPath"), AssetPath);
2100
2238
  Result->SetStringField(TEXT("slotName"), SlotName);
2101
2239
  Result->SetNumberField(TEXT("trackIndex"), TrackIndex);
2102
2240
 
2241
+ // Rollback: self-inverse with previous slot name
2242
+ TSharedPtr<FJsonObject> Payload = MakeShared<FJsonObject>();
2243
+ Payload->SetStringField(TEXT("assetPath"), AssetPath);
2244
+ Payload->SetStringField(TEXT("slotName"), PrevSlot.ToString());
2245
+ Payload->SetNumberField(TEXT("trackIndex"), TrackIndex);
2246
+ MCPSetRollback(Result, TEXT("set_montage_slot"), Payload);
2247
+
2103
2248
  return MCPResult(Result);
2104
2249
  }
2105
2250
 
@@ -2123,11 +2268,21 @@ TSharedPtr<FJsonValue> FAnimationHandlers::AddMontageSection(const TSharedPtr<FJ
2123
2268
  return MCPError(FString::Printf(TEXT("Failed to load AnimMontage at '%s'"), *AssetPath));
2124
2269
  }
2125
2270
 
2126
- // Check if section already exists
2271
+ // Idempotency: existing section short-circuits
2127
2272
  int32 ExistingIdx = Montage->GetSectionIndex(FName(*SectionName));
2128
2273
  if (ExistingIdx != INDEX_NONE)
2129
2274
  {
2130
- return MCPError(FString::Printf(TEXT("Section '%s' already exists at index %d"), *SectionName, ExistingIdx));
2275
+ const FString OnConflict = OptionalString(Params, TEXT("onConflict"), TEXT("skip"));
2276
+ if (OnConflict == TEXT("error"))
2277
+ {
2278
+ return MCPError(FString::Printf(TEXT("Section '%s' already exists at index %d"), *SectionName, ExistingIdx));
2279
+ }
2280
+ auto Existed = MCPSuccess();
2281
+ MCPSetExisted(Existed);
2282
+ Existed->SetStringField(TEXT("assetPath"), AssetPath);
2283
+ Existed->SetStringField(TEXT("sectionName"), SectionName);
2284
+ Existed->SetNumberField(TEXT("sectionIndex"), ExistingIdx);
2285
+ return MCPResult(Existed);
2131
2286
  }
2132
2287
 
2133
2288
  // Add the composite section
@@ -2145,6 +2300,7 @@ TSharedPtr<FJsonValue> FAnimationHandlers::AddMontageSection(const TSharedPtr<FJ
2145
2300
  UEditorAssetLibrary::SaveAsset(AssetPath);
2146
2301
 
2147
2302
  auto Result = MCPSuccess();
2303
+ MCPSetCreated(Result);
2148
2304
  Result->SetStringField(TEXT("assetPath"), AssetPath);
2149
2305
  Result->SetStringField(TEXT("sectionName"), SectionName);
2150
2306
  Result->SetNumberField(TEXT("startTime"), StartTime);
@@ -2153,6 +2309,7 @@ TSharedPtr<FJsonValue> FAnimationHandlers::AddMontageSection(const TSharedPtr<FJ
2153
2309
  Result->SetStringField(TEXT("linkedSection"), LinkedSection);
2154
2310
  }
2155
2311
  Result->SetNumberField(TEXT("totalSections"), Montage->CompositeSections.Num());
2312
+ // No rollback: no paired remove_montage_section handler.
2156
2313
 
2157
2314
  return MCPResult(Result);
2158
2315
  }
@@ -2168,6 +2325,12 @@ TSharedPtr<FJsonValue> FAnimationHandlers::CreateIKRig(const TSharedPtr<FJsonObj
2168
2325
  if (auto Err = RequireString(Params, TEXT("skeletalMeshPath"), SkeletalMeshPath)) return Err;
2169
2326
 
2170
2327
  FString PackagePath = OptionalString(Params, TEXT("packagePath"), TEXT("/Game"));
2328
+ const FString OnConflict = OptionalString(Params, TEXT("onConflict"), TEXT("skip"));
2329
+
2330
+ if (auto Hit = MCPCheckAssetExists(PackagePath, Name, OnConflict, TEXT("IKRigDefinition")))
2331
+ {
2332
+ return Hit;
2333
+ }
2171
2334
 
2172
2335
  // Load the skeletal mesh to get the skeleton
2173
2336
  USkeletalMesh* SkelMesh = LoadObject<USkeletalMesh>(nullptr, *SkeletalMeshPath);
@@ -2198,9 +2361,11 @@ TSharedPtr<FJsonValue> FAnimationHandlers::CreateIKRig(const TSharedPtr<FJsonObj
2198
2361
  UEditorAssetLibrary::SaveAsset(IKRig->GetPathName());
2199
2362
 
2200
2363
  auto Result = MCPSuccess();
2364
+ MCPSetCreated(Result);
2201
2365
  Result->SetStringField(TEXT("assetPath"), IKRig->GetPathName());
2202
2366
  Result->SetStringField(TEXT("name"), Name);
2203
2367
  Result->SetStringField(TEXT("skeletalMeshPath"), SkeletalMeshPath);
2368
+ MCPSetDeleteAssetRollback(Result, IKRig->GetPathName());
2204
2369
 
2205
2370
  return MCPResult(Result);
2206
2371
  }
@@ -2432,16 +2597,33 @@ TSharedPtr<FJsonValue> FAnimationHandlers::RemoveVirtualBone(const TSharedPtr<FJ
2432
2597
  USkeleton* Skeleton = LoadAssetByPath<USkeleton>(SkeletonPath);
2433
2598
  if (!Skeleton) return MCPError(FString::Printf(TEXT("Skeleton not found: %s"), *SkeletonPath));
2434
2599
 
2600
+ // Idempotency: check if virtual bone exists
2601
+ const FName BoneFName(*BoneName);
2602
+ bool bFound = false;
2603
+ for (const FVirtualBone& VB : Skeleton->GetVirtualBones())
2604
+ {
2605
+ if (VB.VirtualBoneName == BoneFName) { bFound = true; break; }
2606
+ }
2607
+ if (!bFound)
2608
+ {
2609
+ auto Noop = MCPSuccess();
2610
+ Noop->SetStringField(TEXT("skeletonPath"), SkeletonPath);
2611
+ Noop->SetStringField(TEXT("virtualBoneName"), BoneName);
2612
+ Noop->SetBoolField(TEXT("alreadyDeleted"), true);
2613
+ return MCPResult(Noop);
2614
+ }
2615
+
2435
2616
  Skeleton->Modify();
2436
- TArray<FName> ToRemove = { FName(*BoneName) };
2617
+ TArray<FName> ToRemove = { BoneFName };
2437
2618
  Skeleton->RemoveVirtualBones(ToRemove);
2438
2619
  Skeleton->PostEditChange();
2439
2620
  UEditorAssetLibrary::SaveLoadedAsset(Skeleton);
2440
2621
 
2441
2622
  TSharedPtr<FJsonObject> Result = MCPSuccess();
2442
- MCPSetUpdated(Result);
2443
2623
  Result->SetStringField(TEXT("skeletonPath"), SkeletonPath);
2444
2624
  Result->SetStringField(TEXT("removed"), BoneName);
2625
+ Result->SetBoolField(TEXT("deleted"), true);
2626
+ // No rollback: removal of a virtual bone is not reversible without source/target capture.
2445
2627
  return MCPResult(Result);
2446
2628
  }
2447
2629
 
@@ -813,9 +813,11 @@ TSharedPtr<FJsonValue> FAssetHandlers::ImportDataTableJson(const TSharedPtr<FJso
813
813
  DataTable->MarkPackageDirty();
814
814
 
815
815
  auto Result = MCPSuccess();
816
+ MCPSetUpdated(Result);
816
817
  Result->SetStringField(TEXT("assetPath"), AssetPath);
817
818
  Result->SetNumberField(TEXT("rowCount"), DataTable->GetRowMap().Num());
818
819
  Result->SetStringField(TEXT("message"), TEXT("DataTable imported successfully from JSON"));
820
+ // No rollback: destructive — import replaces table contents.
819
821
 
820
822
  return MCPResult(Result);
821
823
  }
@@ -1682,9 +1684,12 @@ TSharedPtr<FJsonValue> FAssetHandlers::RecenterPivot(const TSharedPtr<FJsonObjec
1682
1684
  }
1683
1685
 
1684
1686
  auto Result = MCPSuccess();
1687
+ MCPSetUpdated(Result);
1685
1688
  Result->SetArrayField(TEXT("meshes"), ResultArray);
1686
1689
  Result->SetStringField(TEXT("offsetApplied"), FString::Printf(TEXT("(%.2f, %.2f, %.2f)"), Center.X, Center.Y, Center.Z));
1687
1690
  Result->SetNumberField(TEXT("meshCount"), Meshes.Num());
1691
+ // No rollback: destructive/external — vertex offsets applied non-idempotently;
1692
+ // re-running shifts the pivot again. Not natural-key idempotent.
1688
1693
 
1689
1694
  return MCPResult(Result);
1690
1695
  }
@@ -1917,9 +1922,11 @@ TSharedPtr<FJsonValue> FAssetHandlers::ReimportDataTable(const TSharedPtr<FJsonO
1917
1922
  DataTable->MarkPackageDirty();
1918
1923
 
1919
1924
  auto Result = MCPSuccess();
1925
+ MCPSetUpdated(Result);
1920
1926
  Result->SetStringField(TEXT("assetPath"), AssetPath);
1921
1927
  Result->SetNumberField(TEXT("rowCount"), DataTable->GetRowMap().Num());
1922
1928
  Result->SetStringField(TEXT("message"), TEXT("DataTable reimported successfully from JSON"));
1929
+ // No rollback: destructive/external — reimport replaces table contents.
1923
1930
 
1924
1931
  return MCPResult(Result);
1925
1932
  }
@@ -1976,6 +1983,7 @@ TSharedPtr<FJsonValue> FAssetHandlers::ReimportAsset(const TSharedPtr<FJsonObjec
1976
1983
  bool bSuccess = FReimportManager::Instance()->Reimport(Asset, /*bAskForNewFileIfMissing=*/false, /*bShowNotification=*/false);
1977
1984
 
1978
1985
  auto Result = MCPSuccess();
1986
+ if (bSuccess) MCPSetUpdated(Result);
1979
1987
  Result->SetStringField(TEXT("assetPath"), AssetPath);
1980
1988
  Result->SetStringField(TEXT("assetClass"), Asset->GetClass()->GetName());
1981
1989
  Result->SetBoolField(TEXT("success"), bSuccess);
@@ -1983,6 +1991,7 @@ TSharedPtr<FJsonValue> FAssetHandlers::ReimportAsset(const TSharedPtr<FJsonObjec
1983
1991
  {
1984
1992
  Result->SetStringField(TEXT("error"), TEXT("Reimport failed -- check that the asset has a valid source file"));
1985
1993
  }
1994
+ // No rollback: destructive/external — reimport pulls fresh from source file.
1986
1995
 
1987
1996
  return MCPResult(Result);
1988
1997
  }
@@ -199,10 +199,12 @@ TSharedPtr<FJsonValue> FAudioHandlers::PlaySoundAtLocation(const TSharedPtr<FJso
199
199
  Params->TryGetNumberField(TEXT("pitchMultiplier"), Pitch);
200
200
  }
201
201
 
202
- // Play the sound at the specified location
202
+ // No rollback: destructive/external — playing a one-shot sound has no inverse.
203
+ // Replays produce a new audible event; not natural-key idempotent.
203
204
  UGameplayStatics::PlaySoundAtLocation(World, Sound, Location, static_cast<float>(Volume), static_cast<float>(Pitch));
204
205
 
205
206
  auto Result = MCPSuccess();
207
+ MCPSetUpdated(Result);
206
208
  Result->SetStringField(TEXT("assetPath"), SoundPath);
207
209
 
208
210
  return MCPResult(Result);