From b2e8b231fbe9ffaab805d3e971b09418e3d9bcd2 Mon Sep 17 00:00:00 2001 From: jyelon Date: Fri, 6 Mar 2026 20:09:40 -0500 Subject: [PATCH] Put handlers in headers files (MCP) --- .../BlueprintMCP/Private/MCPHandlers.cpp | 5 + .../Private/MCPHandlers_AssetMutation.h | 257 +++ .../Private/MCPHandlers_DiffBlueprints.cpp | 242 --- .../Private/MCPHandlers_DiffBlueprints.h | 245 ++- .../Private/MCPHandlers_Interfaces.cpp | 255 --- .../Private/MCPHandlers_Interfaces.h | 254 ++- .../Private/MCPHandlers_Mutation.cpp | 1777 ----------------- .../Private/MCPHandlers_Mutation.h | 1386 +++++++++++-- .../Private/MCPHandlers_PinMutation.h | 427 ++++ tools/inline-methods.py | 124 ++ 10 files changed, 2538 insertions(+), 2434 deletions(-) create mode 100644 Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers.cpp create mode 100644 Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_AssetMutation.h delete mode 100644 Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_DiffBlueprints.cpp delete mode 100644 Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Interfaces.cpp delete mode 100644 Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Mutation.cpp create mode 100644 Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_PinMutation.h create mode 100644 tools/inline-methods.py diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers.cpp b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers.cpp new file mode 100644 index 00000000..2f712102 --- /dev/null +++ b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers.cpp @@ -0,0 +1,5 @@ +#include "MCPHandlers_DiffBlueprints.h" +#include "MCPHandlers_Interfaces.h" +#include "MCPHandlers_Mutation.h" +#include "MCPHandlers_PinMutation.h" +#include "MCPHandlers_AssetMutation.h" diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_AssetMutation.h b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_AssetMutation.h new file mode 100644 index 00000000..4f6bb1bf --- /dev/null +++ b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_AssetMutation.h @@ -0,0 +1,257 @@ +#pragma once + +#include "CoreMinimal.h" +#include "MCPHandler.h" +#include "MCPAssetFinder.h" +#include "MCPUtils.h" +#include "Engine/Blueprint.h" +#include "Kismet2/BlueprintEditorUtils.h" +#include "UObject/SavePackage.h" +#include "Misc/PackageName.h" +#include "AssetRegistry/AssetRegistryModule.h" +#include "AssetRegistry/IAssetRegistry.h" +#include "AssetToolsModule.h" +#include "IAssetTools.h" +#include "MCPHandlers_AssetMutation.generated.h" + +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + +UCLASS(meta=(ToolName="delete_asset")) +class UMCPHandler_DeleteAsset : public UObject, public IMCPHandler +{ + GENERATED_BODY() + +public: + UPROPERTY(meta=(Description="Package path of the asset to delete")) + FString AssetPath; + + UPROPERTY(meta=(Optional, Description="If true, skip reference check and force delete")) + bool Force = false; + + virtual FString GetDescription() const override + { + return TEXT("Delete a .uasset after verifying no references. " + "Use force=true to skip the reference check."); + } + + virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override + { + + // Check if asset file exists on disk + FString PackageFilename = FPackageName::LongPackageNameToFilename( + AssetPath, FPackageName::GetAssetPackageExtension()); + PackageFilename = FPaths::ConvertRelativePathToFull(PackageFilename); + + if (!IFileManager::Get().FileExists(*PackageFilename)) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Asset file not found on disk: %s"), *PackageFilename)); + } + + // Check references + IAssetRegistry& Registry = *IAssetRegistry::Get(); + TArray Referencers; + Registry.GetReferencers(FName(*AssetPath), Referencers); + + // Filter out self-references + Referencers.RemoveAll([this](const FName& Ref) { + return Ref.ToString() == AssetPath; + }); + + if ((Referencers.Num() > 0) && !Force) + { + // Classify references as "live" (loaded in memory) vs "stale" (only on disk) + TArray> LiveRefs; + TArray> StaleRefs; + for (const FName& Ref : Referencers) + { + FString RefStr = Ref.ToString(); + UPackage* RefPackage = FindPackage(nullptr, *RefStr); + if (RefPackage) + { + LiveRefs.Add(MakeShared(RefStr)); + } + else + { + StaleRefs.Add(MakeShared(RefStr)); + } + } + + MCPUtils::MakeErrorJson(Result, TEXT("Asset is still referenced. Remove all references first.")); + Result->SetStringField(TEXT("assetPath"), AssetPath); + Result->SetNumberField(TEXT("referencerCount"), Referencers.Num()); + Result->SetNumberField(TEXT("liveReferencerCount"), LiveRefs.Num()); + Result->SetArrayField(TEXT("liveReferencers"), LiveRefs); + Result->SetNumberField(TEXT("staleReferencerCount"), StaleRefs.Num()); + Result->SetArrayField(TEXT("staleReferencers"), StaleRefs); + Result->SetStringField(TEXT("suggestion"), + StaleRefs.Num() > 0 + ? TEXT("Some references may be stale. Consider force=true to skip the reference check, or use change_variable_type to migrate references first.") + : TEXT("All references are live. Migrate with change_variable_type or replace_function_calls before deleting.")); + return; + } + + // Force delete: unload the package from memory first + TArray> RefWarnings; + if (Force) + { + // Collect reference warnings when force-deleting with existing references + for (const FName& Ref : Referencers) + { + RefWarnings.Add(MakeShared( + FString::Printf(TEXT("Warning: '%s' still references this asset"), *Ref.ToString()))); + } + + UPackage* Package = FindPackage(nullptr, *AssetPath); + if (Package) + { + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Force-unloading package '%s' from memory"), *AssetPath); + + // Collect all objects in this package + TArray ObjectsInPackage; + GetObjectsWithPackage(Package, ObjectsInPackage); + + // Clear flags and remove from root to allow GC + for (UObject* Obj : ObjectsInPackage) + { + if (Obj) + { + Obj->ClearFlags(RF_Standalone | RF_Public); + Obj->RemoveFromRoot(); + } + } + Package->ClearFlags(RF_Standalone | RF_Public); + Package->RemoveFromRoot(); + + // Reset loaders to release file handles + ResetLoaders(Package); + // Force garbage collection to free the objects + CollectGarbage(GARBAGE_COLLECTION_KEEPFLAGS); + } + } + + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Deleting asset '%s' (%s)%s"), + *AssetPath, *PackageFilename, Force ? TEXT(" [FORCE]") : TEXT("")); + + // Delete the file on disk + bool bDeleted = IFileManager::Get().Delete(*PackageFilename, false, true); + + if (bDeleted) + { + // Trigger an asset registry rescan so it notices the deletion + TArray PathsToScan; + int32 LastSlash; + if (AssetPath.FindLastChar(TEXT('/'), LastSlash)) + { + PathsToScan.Add(AssetPath.Left(LastSlash)); + } + if (PathsToScan.Num() > 0) + { + Registry.ScanPathsSynchronous(PathsToScan, true); + } + } + + Result->SetBoolField(TEXT("success"), bDeleted); + Result->SetStringField(TEXT("assetPath"), AssetPath); + Result->SetStringField(TEXT("filename"), PackageFilename); + Result->SetBoolField(TEXT("forced"), Force); + if (!bDeleted) + { + MCPUtils::MakeErrorJson(Result, TEXT("Failed to delete file from disk")); + } + if (RefWarnings.Num() > 0) + { + Result->SetArrayField(TEXT("warnings"), RefWarnings); + } + } +}; + +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + +UCLASS(meta=(ToolName="rename_asset")) +class UMCPHandler_RenameAsset : public UObject, public IMCPHandler +{ + GENERATED_BODY() + +public: + UPROPERTY(meta=(Description="Current package path of the asset")) + FString AssetPath; + + UPROPERTY(meta=(Description="New package path or new asset name")) + FString NewPath; + + virtual FString GetDescription() const override + { + return TEXT("Rename or move an asset with reference fixup."); + } + + virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override + { + + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Renaming asset '%s' -> '%s'"), *AssetPath, *NewPath); + + // Use FAssetToolsModule to perform the rename with reference fixup + FAssetToolsModule& AssetToolsModule = FModuleManager::LoadModuleChecked("AssetTools"); + IAssetTools& AssetTools = AssetToolsModule.Get(); + + // Build the source/dest arrays + TArray RenameData; + + // We need to load the asset to get the object + FAssetData* FoundAsset = UMCPAssetFinder::FindAnyAsset(AssetPath); + if (!FoundAsset) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Asset '%s' not found. Checked Blueprints, Materials, Material Instances, and Material Functions."), *AssetPath)); + } + + UObject* AssetObj = FoundAsset->GetAsset(); + if (!AssetObj) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Failed to load asset '%s'"), *AssetPath)); + } + + // Parse new path into package path and asset name + FString NewPackagePath, NewAssetName; + int32 LastSlash; + if (NewPath.FindLastChar(TEXT('/'), LastSlash)) + { + NewPackagePath = NewPath.Left(LastSlash); + NewAssetName = NewPath.Mid(LastSlash + 1); + } + else + { + // If no slash, assume same directory with new name + FString OldPackagePath; + if (AssetPath.FindLastChar(TEXT('/'), LastSlash)) + { + OldPackagePath = AssetPath.Left(LastSlash); + } + NewPackagePath = OldPackagePath; + NewAssetName = NewPath; + } + + FAssetRenameData RenameEntry(AssetObj, NewPackagePath, NewAssetName); + RenameData.Add(RenameEntry); + + bool bSuccess = AssetTools.RenameAssets(RenameData); + + if (bSuccess) + { + } + + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Rename %s"), bSuccess ? TEXT("succeeded") : TEXT("failed")); + + Result->SetBoolField(TEXT("success"), bSuccess); + Result->SetStringField(TEXT("oldPath"), AssetPath); + Result->SetStringField(TEXT("newPath"), NewPath); + Result->SetStringField(TEXT("newPackagePath"), NewPackagePath); + Result->SetStringField(TEXT("newAssetName"), NewAssetName); + if (!bSuccess) + { + MCPUtils::MakeErrorJson(Result, TEXT("Asset rename failed. The target path may be invalid or a conflicting asset may exist.")); + } + } +}; diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_DiffBlueprints.cpp b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_DiffBlueprints.cpp deleted file mode 100644 index 899582c2..00000000 --- a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_DiffBlueprints.cpp +++ /dev/null @@ -1,242 +0,0 @@ -#include "MCPHandlers_DiffBlueprints.h" -#include "MCPAssetFinder.h" -#include "MCPServer.h" -#include "MCPUtils.h" -#include "Engine/Blueprint.h" -#include "EdGraph/EdGraph.h" -#include "EdGraph/EdGraphNode.h" -#include "EdGraph/EdGraphPin.h" - -void UMCPHandler_DiffTwoBlueprints::Handle(const FJsonObject* Json, FJsonObject* Result) -{ - - // Load both blueprints - FString LoadErrorA, LoadErrorB; - UBlueprint* BPA = UMCPAssetFinder::LoadBlueprintByName(BlueprintA, LoadErrorA); - if (!BPA) { MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("blueprintA: %s"), *LoadErrorA)); return; } - - UBlueprint* BPB = UMCPAssetFinder::LoadBlueprintByName(BlueprintB, LoadErrorB); - if (!BPB) { MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("blueprintB: %s"), *LoadErrorB)); return; } - - // Helper to gather graphs from a Blueprint - auto GatherGraphs = [this](UBlueprint* BP) -> TArray - { - TArray Graphs; - for (UEdGraph* G : BP->UbergraphPages) - { - if (!G) continue; - if (!Graph.IsEmpty() && (G->GetName() != Graph)) continue; - Graphs.Add(G); - } - for (UEdGraph* G : BP->FunctionGraphs) - { - if (!G) continue; - if (!Graph.IsEmpty() && (G->GetName() != Graph)) continue; - Graphs.Add(G); - } - return Graphs; - }; - - TArray GraphsA = GatherGraphs(BPA); - TArray GraphsB = GatherGraphs(BPB); - - // Build graph name maps - TMap GraphMapA, GraphMapB; - for (UEdGraph* G : GraphsA) GraphMapA.Add(G->GetName(), G); - for (UEdGraph* G : GraphsB) GraphMapB.Add(G->GetName(), G); - - // Compare graphs - TArray> GraphDiffs; - - // Find all unique graph names - TSet AllGraphNames; - for (auto& Pair : GraphMapA) AllGraphNames.Add(Pair.Key); - for (auto& Pair : GraphMapB) AllGraphNames.Add(Pair.Key); - - for (const FString& GraphName : AllGraphNames) - { - UEdGraph** pGA = GraphMapA.Find(GraphName); - UEdGraph** pGB = GraphMapB.Find(GraphName); - - TSharedRef GD = MakeShared(); - GD->SetStringField(TEXT("graph"), GraphName); - - if (!pGA) - { - GD->SetStringField(TEXT("status"), TEXT("onlyInB")); - GD->SetNumberField(TEXT("nodeCountB"), (*pGB)->Nodes.Num()); - GraphDiffs.Add(MakeShared(GD)); - continue; - } - if (!pGB) - { - GD->SetStringField(TEXT("status"), TEXT("onlyInA")); - GD->SetNumberField(TEXT("nodeCountA"), (*pGA)->Nodes.Num()); - GraphDiffs.Add(MakeShared(GD)); - continue; - } - - // Both exist — compare nodes - UEdGraph* GA = *pGA; - UEdGraph* GB = *pGB; - - // Build node title maps for matching (title -> node list for each) - TMap> NodesA, NodesB; - for (UEdGraphNode* N : GA->Nodes) - { - if (!N) continue; - FString Title = N->GetNodeTitle(ENodeTitleType::FullTitle).ToString(); - NodesA.FindOrAdd(Title).Add(N); - } - for (UEdGraphNode* N : GB->Nodes) - { - if (!N) continue; - FString Title = N->GetNodeTitle(ENodeTitleType::FullTitle).ToString(); - NodesB.FindOrAdd(Title).Add(N); - } - - // Nodes only in A - TArray> OnlyInA; - for (auto& Pair : NodesA) - { - int32 CountA = Pair.Value.Num(); - int32 CountB = 0; - if (TArray* pArr = NodesB.Find(Pair.Key)) - { - CountB = pArr->Num(); - } - if (CountA > CountB) - { - TSharedRef NObj = MakeShared(); - NObj->SetStringField(TEXT("title"), Pair.Key); - NObj->SetStringField(TEXT("class"), Pair.Value[0]->GetClass()->GetName()); - NObj->SetNumberField(TEXT("extraCount"), CountA - CountB); - OnlyInA.Add(MakeShared(NObj)); - } - } - - // Nodes only in B - TArray> OnlyInB; - for (auto& Pair : NodesB) - { - int32 CountB = Pair.Value.Num(); - int32 CountA = 0; - if (TArray* pArr = NodesA.Find(Pair.Key)) - { - CountA = pArr->Num(); - } - if (CountB > CountA) - { - TSharedRef NObj = MakeShared(); - NObj->SetStringField(TEXT("title"), Pair.Key); - NObj->SetStringField(TEXT("class"), Pair.Value[0]->GetClass()->GetName()); - NObj->SetNumberField(TEXT("extraCount"), CountB - CountA); - OnlyInB.Add(MakeShared(NObj)); - } - } - - // Connection diff: use connection key approach - auto MakeConnKey = [](UEdGraphPin* SrcPin, UEdGraphPin* TgtPin) -> FString - { - FString SrcTitle = SrcPin->GetOwningNode()->GetNodeTitle(ENodeTitleType::FullTitle).ToString(); - FString TgtTitle = TgtPin->GetOwningNode()->GetNodeTitle(ENodeTitleType::FullTitle).ToString(); - return FString::Printf(TEXT("%s|%s|%s|%s"), *SrcTitle, *SrcPin->PinName.ToString(), *TgtTitle, *TgtPin->PinName.ToString()); - }; - - TSet ConnectionsA, ConnectionsB; - for (UEdGraphNode* N : GA->Nodes) - { - if (!N) continue; - for (UEdGraphPin* Pin : N->Pins) - { - if (!Pin || (Pin->Direction != EGPD_Output)) continue; - for (UEdGraphPin* Linked : Pin->LinkedTo) - { - if (!Linked || !Linked->GetOwningNode()) continue; - ConnectionsA.Add(MakeConnKey(Pin, Linked)); - } - } - } - for (UEdGraphNode* N : GB->Nodes) - { - if (!N) continue; - for (UEdGraphPin* Pin : N->Pins) - { - if (!Pin || (Pin->Direction != EGPD_Output)) continue; - for (UEdGraphPin* Linked : Pin->LinkedTo) - { - if (!Linked || !Linked->GetOwningNode()) continue; - ConnectionsB.Add(MakeConnKey(Pin, Linked)); - } - } - } - - TArray> ConnsOnlyInA, ConnsOnlyInB; - for (const FString& Key : ConnectionsA) - { - if (!ConnectionsB.Contains(Key)) - { - ConnsOnlyInA.Add(MakeShared(Key)); - } - } - for (const FString& Key : ConnectionsB) - { - if (!ConnectionsA.Contains(Key)) - { - ConnsOnlyInB.Add(MakeShared(Key)); - } - } - - bool bIdentical = (OnlyInA.Num() == 0) && (OnlyInB.Num() == 0) && (ConnsOnlyInA.Num() == 0) && (ConnsOnlyInB.Num() == 0); - GD->SetStringField(TEXT("status"), bIdentical ? TEXT("identical") : TEXT("different")); - GD->SetNumberField(TEXT("nodeCountA"), GA->Nodes.Num()); - GD->SetNumberField(TEXT("nodeCountB"), GB->Nodes.Num()); - - if (OnlyInA.Num() > 0) GD->SetArrayField(TEXT("nodesOnlyInA"), OnlyInA); - if (OnlyInB.Num() > 0) GD->SetArrayField(TEXT("nodesOnlyInB"), OnlyInB); - if (ConnsOnlyInA.Num() > 0) GD->SetArrayField(TEXT("connectionsOnlyInA"), ConnsOnlyInA); - if (ConnsOnlyInB.Num() > 0) GD->SetArrayField(TEXT("connectionsOnlyInB"), ConnsOnlyInB); - - GraphDiffs.Add(MakeShared(GD)); - } - - // Compare variables - TArray> VarsOnlyInA, VarsOnlyInB; - TSet VarNamesA, VarNamesB; - for (const FBPVariableDescription& V : BPA->NewVariables) VarNamesA.Add(V.VarName.ToString()); - for (const FBPVariableDescription& V : BPB->NewVariables) VarNamesB.Add(V.VarName.ToString()); - - for (const FString& Name : VarNamesA) - { - if (!VarNamesB.Contains(Name)) - { - VarsOnlyInA.Add(MakeShared(Name)); - } - } - for (const FString& Name : VarNamesB) - { - if (!VarNamesA.Contains(Name)) - { - VarsOnlyInB.Add(MakeShared(Name)); - } - } - - Result->SetBoolField(TEXT("success"), true); - Result->SetStringField(TEXT("blueprintA"), BlueprintA); - Result->SetStringField(TEXT("blueprintB"), BlueprintB); - Result->SetArrayField(TEXT("graphs"), GraphDiffs); - - if (VarsOnlyInA.Num() > 0) Result->SetArrayField(TEXT("variablesOnlyInA"), VarsOnlyInA); - if (VarsOnlyInB.Num() > 0) Result->SetArrayField(TEXT("variablesOnlyInB"), VarsOnlyInB); - - // Summary counts - int32 TotalDiffs = 0; - for (auto& GDVal : GraphDiffs) - { - auto GDObj = GDVal->AsObject(); - FString Status = GDObj->GetStringField(TEXT("status")); - if (Status != TEXT("identical")) TotalDiffs++; - } - TotalDiffs += VarsOnlyInA.Num() + VarsOnlyInB.Num(); - Result->SetNumberField(TEXT("totalDifferences"), TotalDiffs); -} diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_DiffBlueprints.h b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_DiffBlueprints.h index f4ab9739..2cf4c208 100644 --- a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_DiffBlueprints.h +++ b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_DiffBlueprints.h @@ -2,8 +2,19 @@ #include "CoreMinimal.h" #include "MCPHandler.h" +#include "MCPAssetFinder.h" +#include "MCPServer.h" +#include "MCPUtils.h" +#include "Engine/Blueprint.h" +#include "EdGraph/EdGraph.h" +#include "EdGraph/EdGraphNode.h" +#include "EdGraph/EdGraphPin.h" #include "MCPHandlers_DiffBlueprints.generated.h" +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + UCLASS(meta=(ToolName="diff_two_blueprints")) class UMCPHandler_DiffTwoBlueprints : public UObject, public IMCPHandler { @@ -26,5 +37,237 @@ public: "finding divergence after copy-paste, or auditing consistency."); } - virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override; + virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override + { + + // Load both blueprints + FString LoadErrorA, LoadErrorB; + UBlueprint* BPA = UMCPAssetFinder::LoadBlueprintByName(BlueprintA, LoadErrorA); + if (!BPA) { MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("blueprintA: %s"), *LoadErrorA)); return; } + + UBlueprint* BPB = UMCPAssetFinder::LoadBlueprintByName(BlueprintB, LoadErrorB); + if (!BPB) { MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("blueprintB: %s"), *LoadErrorB)); return; } + + // Helper to gather graphs from a Blueprint + auto GatherGraphs = [this](UBlueprint* BP) -> TArray + { + TArray Graphs; + for (UEdGraph* G : BP->UbergraphPages) + { + if (!G) continue; + if (!Graph.IsEmpty() && (G->GetName() != Graph)) continue; + Graphs.Add(G); + } + for (UEdGraph* G : BP->FunctionGraphs) + { + if (!G) continue; + if (!Graph.IsEmpty() && (G->GetName() != Graph)) continue; + Graphs.Add(G); + } + return Graphs; + }; + + TArray GraphsA = GatherGraphs(BPA); + TArray GraphsB = GatherGraphs(BPB); + + // Build graph name maps + TMap GraphMapA, GraphMapB; + for (UEdGraph* G : GraphsA) GraphMapA.Add(G->GetName(), G); + for (UEdGraph* G : GraphsB) GraphMapB.Add(G->GetName(), G); + + // Compare graphs + TArray> GraphDiffs; + + // Find all unique graph names + TSet AllGraphNames; + for (auto& Pair : GraphMapA) AllGraphNames.Add(Pair.Key); + for (auto& Pair : GraphMapB) AllGraphNames.Add(Pair.Key); + + for (const FString& GraphName : AllGraphNames) + { + UEdGraph** pGA = GraphMapA.Find(GraphName); + UEdGraph** pGB = GraphMapB.Find(GraphName); + + TSharedRef GD = MakeShared(); + GD->SetStringField(TEXT("graph"), GraphName); + + if (!pGA) + { + GD->SetStringField(TEXT("status"), TEXT("onlyInB")); + GD->SetNumberField(TEXT("nodeCountB"), (*pGB)->Nodes.Num()); + GraphDiffs.Add(MakeShared(GD)); + continue; + } + if (!pGB) + { + GD->SetStringField(TEXT("status"), TEXT("onlyInA")); + GD->SetNumberField(TEXT("nodeCountA"), (*pGA)->Nodes.Num()); + GraphDiffs.Add(MakeShared(GD)); + continue; + } + + // Both exist — compare nodes + UEdGraph* GA = *pGA; + UEdGraph* GB = *pGB; + + // Build node title maps for matching (title -> node list for each) + TMap> NodesA, NodesB; + for (UEdGraphNode* N : GA->Nodes) + { + if (!N) continue; + FString Title = N->GetNodeTitle(ENodeTitleType::FullTitle).ToString(); + NodesA.FindOrAdd(Title).Add(N); + } + for (UEdGraphNode* N : GB->Nodes) + { + if (!N) continue; + FString Title = N->GetNodeTitle(ENodeTitleType::FullTitle).ToString(); + NodesB.FindOrAdd(Title).Add(N); + } + + // Nodes only in A + TArray> OnlyInA; + for (auto& Pair : NodesA) + { + int32 CountA = Pair.Value.Num(); + int32 CountB = 0; + if (TArray* pArr = NodesB.Find(Pair.Key)) + { + CountB = pArr->Num(); + } + if (CountA > CountB) + { + TSharedRef NObj = MakeShared(); + NObj->SetStringField(TEXT("title"), Pair.Key); + NObj->SetStringField(TEXT("class"), Pair.Value[0]->GetClass()->GetName()); + NObj->SetNumberField(TEXT("extraCount"), CountA - CountB); + OnlyInA.Add(MakeShared(NObj)); + } + } + + // Nodes only in B + TArray> OnlyInB; + for (auto& Pair : NodesB) + { + int32 CountB = Pair.Value.Num(); + int32 CountA = 0; + if (TArray* pArr = NodesA.Find(Pair.Key)) + { + CountA = pArr->Num(); + } + if (CountB > CountA) + { + TSharedRef NObj = MakeShared(); + NObj->SetStringField(TEXT("title"), Pair.Key); + NObj->SetStringField(TEXT("class"), Pair.Value[0]->GetClass()->GetName()); + NObj->SetNumberField(TEXT("extraCount"), CountB - CountA); + OnlyInB.Add(MakeShared(NObj)); + } + } + + // Connection diff: use connection key approach + auto MakeConnKey = [](UEdGraphPin* SrcPin, UEdGraphPin* TgtPin) -> FString + { + FString SrcTitle = SrcPin->GetOwningNode()->GetNodeTitle(ENodeTitleType::FullTitle).ToString(); + FString TgtTitle = TgtPin->GetOwningNode()->GetNodeTitle(ENodeTitleType::FullTitle).ToString(); + return FString::Printf(TEXT("%s|%s|%s|%s"), *SrcTitle, *SrcPin->PinName.ToString(), *TgtTitle, *TgtPin->PinName.ToString()); + }; + + TSet ConnectionsA, ConnectionsB; + for (UEdGraphNode* N : GA->Nodes) + { + if (!N) continue; + for (UEdGraphPin* Pin : N->Pins) + { + if (!Pin || (Pin->Direction != EGPD_Output)) continue; + for (UEdGraphPin* Linked : Pin->LinkedTo) + { + if (!Linked || !Linked->GetOwningNode()) continue; + ConnectionsA.Add(MakeConnKey(Pin, Linked)); + } + } + } + for (UEdGraphNode* N : GB->Nodes) + { + if (!N) continue; + for (UEdGraphPin* Pin : N->Pins) + { + if (!Pin || (Pin->Direction != EGPD_Output)) continue; + for (UEdGraphPin* Linked : Pin->LinkedTo) + { + if (!Linked || !Linked->GetOwningNode()) continue; + ConnectionsB.Add(MakeConnKey(Pin, Linked)); + } + } + } + + TArray> ConnsOnlyInA, ConnsOnlyInB; + for (const FString& Key : ConnectionsA) + { + if (!ConnectionsB.Contains(Key)) + { + ConnsOnlyInA.Add(MakeShared(Key)); + } + } + for (const FString& Key : ConnectionsB) + { + if (!ConnectionsA.Contains(Key)) + { + ConnsOnlyInB.Add(MakeShared(Key)); + } + } + + bool bIdentical = (OnlyInA.Num() == 0) && (OnlyInB.Num() == 0) && (ConnsOnlyInA.Num() == 0) && (ConnsOnlyInB.Num() == 0); + GD->SetStringField(TEXT("status"), bIdentical ? TEXT("identical") : TEXT("different")); + GD->SetNumberField(TEXT("nodeCountA"), GA->Nodes.Num()); + GD->SetNumberField(TEXT("nodeCountB"), GB->Nodes.Num()); + + if (OnlyInA.Num() > 0) GD->SetArrayField(TEXT("nodesOnlyInA"), OnlyInA); + if (OnlyInB.Num() > 0) GD->SetArrayField(TEXT("nodesOnlyInB"), OnlyInB); + if (ConnsOnlyInA.Num() > 0) GD->SetArrayField(TEXT("connectionsOnlyInA"), ConnsOnlyInA); + if (ConnsOnlyInB.Num() > 0) GD->SetArrayField(TEXT("connectionsOnlyInB"), ConnsOnlyInB); + + GraphDiffs.Add(MakeShared(GD)); + } + + // Compare variables + TArray> VarsOnlyInA, VarsOnlyInB; + TSet VarNamesA, VarNamesB; + for (const FBPVariableDescription& V : BPA->NewVariables) VarNamesA.Add(V.VarName.ToString()); + for (const FBPVariableDescription& V : BPB->NewVariables) VarNamesB.Add(V.VarName.ToString()); + + for (const FString& Name : VarNamesA) + { + if (!VarNamesB.Contains(Name)) + { + VarsOnlyInA.Add(MakeShared(Name)); + } + } + for (const FString& Name : VarNamesB) + { + if (!VarNamesA.Contains(Name)) + { + VarsOnlyInB.Add(MakeShared(Name)); + } + } + + Result->SetBoolField(TEXT("success"), true); + Result->SetStringField(TEXT("blueprintA"), BlueprintA); + Result->SetStringField(TEXT("blueprintB"), BlueprintB); + Result->SetArrayField(TEXT("graphs"), GraphDiffs); + + if (VarsOnlyInA.Num() > 0) Result->SetArrayField(TEXT("variablesOnlyInA"), VarsOnlyInA); + if (VarsOnlyInB.Num() > 0) Result->SetArrayField(TEXT("variablesOnlyInB"), VarsOnlyInB); + + // Summary counts + int32 TotalDiffs = 0; + for (auto& GDVal : GraphDiffs) + { + auto GDObj = GDVal->AsObject(); + FString Status = GDObj->GetStringField(TEXT("status")); + if (Status != TEXT("identical")) TotalDiffs++; + } + TotalDiffs += VarsOnlyInA.Num() + VarsOnlyInB.Num(); + Result->SetNumberField(TEXT("totalDifferences"), TotalDiffs); + } }; diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Interfaces.cpp b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Interfaces.cpp deleted file mode 100644 index 5f91a5ee..00000000 --- a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Interfaces.cpp +++ /dev/null @@ -1,255 +0,0 @@ -#include "MCPHandlers_Interfaces.h" -#include "MCPAssetFinder.h" -#include "MCPServer.h" -#include "MCPUtils.h" -#include "Engine/Blueprint.h" -#include "EdGraph/EdGraph.h" -#include "Kismet2/BlueprintEditorUtils.h" -#include "UObject/UObjectIterator.h" - -// ============================================================ -// ListInterfaces -// ============================================================ - -void UMCPHandler_ListBlueprintInterfaces::Handle(const FJsonObject* Json, FJsonObject* Result) -{ - - FString LoadError; - UBlueprint* BP = UMCPAssetFinder::LoadBlueprintByName(Blueprint, LoadError); - if (!BP) - { - return MCPUtils::MakeErrorJson(Result, LoadError); - } - - TArray> InterfacesArr; - for (const FBPInterfaceDescription& IfaceDesc : BP->ImplementedInterfaces) - { - if (!IfaceDesc.Interface) - { - continue; - } - - TSharedRef IfaceObj = MakeShared(); - IfaceObj->SetStringField(TEXT("name"), IfaceDesc.Interface->GetName()); - IfaceObj->SetStringField(TEXT("classPath"), IfaceDesc.Interface->GetPathName()); - - TArray> FuncArr; - for (const UEdGraph* Graph : IfaceDesc.Graphs) - { - if (Graph) - { - FuncArr.Add(MakeShared(Graph->GetName())); - } - } - IfaceObj->SetArrayField(TEXT("functions"), FuncArr); - - InterfacesArr.Add(MakeShared(IfaceObj)); - } - - Result->SetStringField(TEXT("blueprint"), Blueprint); - Result->SetNumberField(TEXT("count"), InterfacesArr.Num()); - Result->SetArrayField(TEXT("interfaces"), InterfacesArr); -} - -// ============================================================ -// AddInterface -// ============================================================ - -void UMCPHandler_AddBlueprintInterface::Handle(const FJsonObject* Json, FJsonObject* Result) -{ - - FString LoadError; - UBlueprint* BP = UMCPAssetFinder::LoadBlueprintByName(Blueprint, LoadError); - if (!BP) - { - return MCPUtils::MakeErrorJson(Result, LoadError); - } - - // Resolve the interface class - UClass* InterfaceClass = nullptr; - - // Strategy 1: Search loaded UInterface classes by name - for (TObjectIterator It; It; ++It) - { - if (!It->IsChildOf(UInterface::StaticClass())) - { - continue; - } - - FString ClassName = It->GetName(); - // Match by class name (e.g. "BPI_Foo_C") or by trimmed name (e.g. "BPI_Foo") - if (ClassName.Equals(InterfaceName, ESearchCase::IgnoreCase)) - { - InterfaceClass = *It; - break; - } - // Strip the generated "_C" suffix for comparison - FString TrimmedName = ClassName; - if (TrimmedName.EndsWith(TEXT("_C"))) - { - TrimmedName = TrimmedName.LeftChop(2); - } - if (TrimmedName.Equals(InterfaceName, ESearchCase::IgnoreCase)) - { - InterfaceClass = *It; - break; - } - } - - // Strategy 2: Try loading as a Blueprint Interface asset - if (!InterfaceClass) - { - FString IfaceLoadError; - UBlueprint* IfaceBP = UMCPAssetFinder::LoadBlueprintByName(InterfaceName, IfaceLoadError); - if (IfaceBP && IfaceBP->GeneratedClass && IfaceBP->GeneratedClass->IsChildOf(UInterface::StaticClass())) - { - InterfaceClass = IfaceBP->GeneratedClass; - } - } - - if (!InterfaceClass) - { - return MCPUtils::MakeErrorJson(Result, FString::Printf( - TEXT("Interface '%s' not found. Provide a Blueprint Interface asset name (e.g. 'BPI_MyInterface') or a native UInterface class name."), - *InterfaceName)); - } - - // Check for duplicates - for (const FBPInterfaceDescription& IfaceDesc : BP->ImplementedInterfaces) - { - if (IfaceDesc.Interface == InterfaceClass) - { - return MCPUtils::MakeErrorJson(Result, FString::Printf( - TEXT("Interface '%s' is already implemented by Blueprint '%s'"), - *InterfaceName, *Blueprint)); - } - } - - FTopLevelAssetPath InterfacePath = InterfaceClass->GetClassPathName(); - - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Adding interface '%s' to Blueprint '%s'"), - *InterfaceClass->GetName(), *Blueprint); - - bool bAdded = FBlueprintEditorUtils::ImplementNewInterface(BP, InterfacePath); - if (!bAdded) - { - return MCPUtils::MakeErrorJson(Result, FString::Printf( - TEXT("FBlueprintEditorUtils::ImplementNewInterface failed for interface '%s' on Blueprint '%s'"), - *InterfaceName, *Blueprint)); - } - - // Collect stub function graph names from the newly added interface entry - TArray AddedFunctions; - for (const FBPInterfaceDescription& IfaceDesc : BP->ImplementedInterfaces) - { - if (IfaceDesc.Interface == InterfaceClass) - { - for (const UEdGraph* Graph : IfaceDesc.Graphs) - { - if (Graph) - { - AddedFunctions.Add(Graph->GetName()); - } - } - break; - } - } - - FBlueprintEditorUtils::MarkBlueprintAsStructurallyModified(BP); - - - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Added interface '%s' to '%s' (%d function stubs)"), - *InterfaceClass->GetName(), *Blueprint, AddedFunctions.Num()); - - Result->SetBoolField(TEXT("success"), true); - Result->SetStringField(TEXT("blueprint"), Blueprint); - Result->SetStringField(TEXT("interfaceName"), InterfaceClass->GetName()); - Result->SetStringField(TEXT("interfacePath"), InterfaceClass->GetPathName()); - - TArray> FuncArr; - for (const FString& FuncName : AddedFunctions) - { - FuncArr.Add(MakeShared(FuncName)); - } - Result->SetArrayField(TEXT("functionGraphsAdded"), FuncArr); -} - -// ============================================================ -// RemoveInterface -// ============================================================ - -void UMCPHandler_RemoveBlueprintInterface::Handle(const FJsonObject* Json, FJsonObject* Result) -{ - - FString LoadError; - UBlueprint* BP = UMCPAssetFinder::LoadBlueprintByName(Blueprint, LoadError); - if (!BP) - { - return MCPUtils::MakeErrorJson(Result, LoadError); - } - - // Find the interface in ImplementedInterfaces by name (case-insensitive) - UClass* FoundInterface = nullptr; - for (const FBPInterfaceDescription& IfaceDesc : BP->ImplementedInterfaces) - { - if (!IfaceDesc.Interface) - { - continue; - } - - FString ClassName = IfaceDesc.Interface->GetName(); - if (ClassName.Equals(InterfaceName, ESearchCase::IgnoreCase)) - { - FoundInterface = IfaceDesc.Interface; - break; - } - // Strip "_C" suffix for comparison - FString TrimmedName = ClassName; - if (TrimmedName.EndsWith(TEXT("_C"))) - { - TrimmedName = TrimmedName.LeftChop(2); - } - if (TrimmedName.Equals(InterfaceName, ESearchCase::IgnoreCase)) - { - FoundInterface = IfaceDesc.Interface; - break; - } - } - - if (!FoundInterface) - { - // Build helpful error with list of implemented interfaces - TArray> IfaceList; - for (const FBPInterfaceDescription& IfaceDesc : BP->ImplementedInterfaces) - { - if (IfaceDesc.Interface) - { - IfaceList.Add(MakeShared(IfaceDesc.Interface->GetName())); - } - } - - MCPUtils::MakeErrorJson(Result, FString::Printf( - TEXT("Interface '%s' is not implemented by Blueprint '%s'"), - *InterfaceName, *Blueprint)); - Result->SetArrayField(TEXT("implementedInterfaces"), IfaceList); - return; - } - - FTopLevelAssetPath InterfacePath = FoundInterface->GetClassPathName(); - - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Removing interface '%s' from Blueprint '%s' (preserveFunctions: %s)"), - *FoundInterface->GetName(), *Blueprint, PreserveFunctions ? TEXT("true") : TEXT("false")); - - FBlueprintEditorUtils::RemoveInterface(BP, InterfacePath, PreserveFunctions); - - FBlueprintEditorUtils::MarkBlueprintAsStructurallyModified(BP); - - - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Removed interface '%s' from '%s'"), - *FoundInterface->GetName(), *Blueprint); - - Result->SetBoolField(TEXT("success"), true); - Result->SetStringField(TEXT("blueprint"), Blueprint); - Result->SetStringField(TEXT("interfaceName"), FoundInterface->GetName()); - Result->SetBoolField(TEXT("preservedFunctions"), PreserveFunctions); -} diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Interfaces.h b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Interfaces.h index 00ad3430..9c145566 100644 --- a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Interfaces.h +++ b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Interfaces.h @@ -2,8 +2,19 @@ #include "CoreMinimal.h" #include "MCPHandler.h" +#include "MCPAssetFinder.h" +#include "MCPServer.h" +#include "MCPUtils.h" +#include "Engine/Blueprint.h" +#include "EdGraph/EdGraph.h" +#include "Kismet2/BlueprintEditorUtils.h" +#include "UObject/UObjectIterator.h" #include "MCPHandlers_Interfaces.generated.h" +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + UCLASS(meta=(ToolName="list_blueprint_interfaces")) class UMCPHandler_ListBlueprintInterfaces : public UObject, public IMCPHandler { @@ -19,9 +30,51 @@ public: "including their function graphs."); } - virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override; + virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override + { + + FString LoadError; + UBlueprint* BP = UMCPAssetFinder::LoadBlueprintByName(Blueprint, LoadError); + if (!BP) + { + return MCPUtils::MakeErrorJson(Result, LoadError); + } + + TArray> InterfacesArr; + for (const FBPInterfaceDescription& IfaceDesc : BP->ImplementedInterfaces) + { + if (!IfaceDesc.Interface) + { + continue; + } + + TSharedRef IfaceObj = MakeShared(); + IfaceObj->SetStringField(TEXT("name"), IfaceDesc.Interface->GetName()); + IfaceObj->SetStringField(TEXT("classPath"), IfaceDesc.Interface->GetPathName()); + + TArray> FuncArr; + for (const UEdGraph* Graph : IfaceDesc.Graphs) + { + if (Graph) + { + FuncArr.Add(MakeShared(Graph->GetName())); + } + } + IfaceObj->SetArrayField(TEXT("functions"), FuncArr); + + InterfacesArr.Add(MakeShared(IfaceObj)); + } + + Result->SetStringField(TEXT("blueprint"), Blueprint); + Result->SetNumberField(TEXT("count"), InterfacesArr.Num()); + Result->SetArrayField(TEXT("interfaces"), InterfacesArr); + } }; +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + UCLASS(meta=(ToolName="add_blueprint_interface")) class UMCPHandler_AddBlueprintInterface : public UObject, public IMCPHandler { @@ -40,9 +93,130 @@ public: "Creates stub function graphs for each interface function."); } - virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override; + virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override + { + + FString LoadError; + UBlueprint* BP = UMCPAssetFinder::LoadBlueprintByName(Blueprint, LoadError); + if (!BP) + { + return MCPUtils::MakeErrorJson(Result, LoadError); + } + + // Resolve the interface class + UClass* InterfaceClass = nullptr; + + // Strategy 1: Search loaded UInterface classes by name + for (TObjectIterator It; It; ++It) + { + if (!It->IsChildOf(UInterface::StaticClass())) + { + continue; + } + + FString ClassName = It->GetName(); + // Match by class name (e.g. "BPI_Foo_C") or by trimmed name (e.g. "BPI_Foo") + if (ClassName.Equals(InterfaceName, ESearchCase::IgnoreCase)) + { + InterfaceClass = *It; + break; + } + // Strip the generated "_C" suffix for comparison + FString TrimmedName = ClassName; + if (TrimmedName.EndsWith(TEXT("_C"))) + { + TrimmedName = TrimmedName.LeftChop(2); + } + if (TrimmedName.Equals(InterfaceName, ESearchCase::IgnoreCase)) + { + InterfaceClass = *It; + break; + } + } + + // Strategy 2: Try loading as a Blueprint Interface asset + if (!InterfaceClass) + { + FString IfaceLoadError; + UBlueprint* IfaceBP = UMCPAssetFinder::LoadBlueprintByName(InterfaceName, IfaceLoadError); + if (IfaceBP && IfaceBP->GeneratedClass && IfaceBP->GeneratedClass->IsChildOf(UInterface::StaticClass())) + { + InterfaceClass = IfaceBP->GeneratedClass; + } + } + + if (!InterfaceClass) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf( + TEXT("Interface '%s' not found. Provide a Blueprint Interface asset name (e.g. 'BPI_MyInterface') or a native UInterface class name."), + *InterfaceName)); + } + + // Check for duplicates + for (const FBPInterfaceDescription& IfaceDesc : BP->ImplementedInterfaces) + { + if (IfaceDesc.Interface == InterfaceClass) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf( + TEXT("Interface '%s' is already implemented by Blueprint '%s'"), + *InterfaceName, *Blueprint)); + } + } + + FTopLevelAssetPath InterfacePath = InterfaceClass->GetClassPathName(); + + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Adding interface '%s' to Blueprint '%s'"), + *InterfaceClass->GetName(), *Blueprint); + + bool bAdded = FBlueprintEditorUtils::ImplementNewInterface(BP, InterfacePath); + if (!bAdded) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf( + TEXT("FBlueprintEditorUtils::ImplementNewInterface failed for interface '%s' on Blueprint '%s'"), + *InterfaceName, *Blueprint)); + } + + // Collect stub function graph names from the newly added interface entry + TArray AddedFunctions; + for (const FBPInterfaceDescription& IfaceDesc : BP->ImplementedInterfaces) + { + if (IfaceDesc.Interface == InterfaceClass) + { + for (const UEdGraph* Graph : IfaceDesc.Graphs) + { + if (Graph) + { + AddedFunctions.Add(Graph->GetName()); + } + } + break; + } + } + + FBlueprintEditorUtils::MarkBlueprintAsStructurallyModified(BP); + + + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Added interface '%s' to '%s' (%d function stubs)"), + *InterfaceClass->GetName(), *Blueprint, AddedFunctions.Num()); + + Result->SetBoolField(TEXT("success"), true); + Result->SetStringField(TEXT("blueprint"), Blueprint); + Result->SetStringField(TEXT("interfaceName"), InterfaceClass->GetName()); + Result->SetStringField(TEXT("interfacePath"), InterfaceClass->GetPathName()); + + TArray> FuncArr; + for (const FString& FuncName : AddedFunctions) + { + FuncArr.Add(MakeShared(FuncName)); + } + Result->SetArrayField(TEXT("functionGraphsAdded"), FuncArr); + } }; +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + UCLASS(meta=(ToolName="remove_blueprint_interface")) class UMCPHandler_RemoveBlueprintInterface : public UObject, public IMCPHandler { @@ -64,5 +238,79 @@ public: "Optionally preserve the function graphs as regular functions."); } - virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override; + virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override + { + + FString LoadError; + UBlueprint* BP = UMCPAssetFinder::LoadBlueprintByName(Blueprint, LoadError); + if (!BP) + { + return MCPUtils::MakeErrorJson(Result, LoadError); + } + + // Find the interface in ImplementedInterfaces by name (case-insensitive) + UClass* FoundInterface = nullptr; + for (const FBPInterfaceDescription& IfaceDesc : BP->ImplementedInterfaces) + { + if (!IfaceDesc.Interface) + { + continue; + } + + FString ClassName = IfaceDesc.Interface->GetName(); + if (ClassName.Equals(InterfaceName, ESearchCase::IgnoreCase)) + { + FoundInterface = IfaceDesc.Interface; + break; + } + // Strip "_C" suffix for comparison + FString TrimmedName = ClassName; + if (TrimmedName.EndsWith(TEXT("_C"))) + { + TrimmedName = TrimmedName.LeftChop(2); + } + if (TrimmedName.Equals(InterfaceName, ESearchCase::IgnoreCase)) + { + FoundInterface = IfaceDesc.Interface; + break; + } + } + + if (!FoundInterface) + { + // Build helpful error with list of implemented interfaces + TArray> IfaceList; + for (const FBPInterfaceDescription& IfaceDesc : BP->ImplementedInterfaces) + { + if (IfaceDesc.Interface) + { + IfaceList.Add(MakeShared(IfaceDesc.Interface->GetName())); + } + } + + MCPUtils::MakeErrorJson(Result, FString::Printf( + TEXT("Interface '%s' is not implemented by Blueprint '%s'"), + *InterfaceName, *Blueprint)); + Result->SetArrayField(TEXT("implementedInterfaces"), IfaceList); + return; + } + + FTopLevelAssetPath InterfacePath = FoundInterface->GetClassPathName(); + + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Removing interface '%s' from Blueprint '%s' (preserveFunctions: %s)"), + *FoundInterface->GetName(), *Blueprint, PreserveFunctions ? TEXT("true") : TEXT("false")); + + FBlueprintEditorUtils::RemoveInterface(BP, InterfacePath, PreserveFunctions); + + FBlueprintEditorUtils::MarkBlueprintAsStructurallyModified(BP); + + + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Removed interface '%s' from '%s'"), + *FoundInterface->GetName(), *Blueprint); + + Result->SetBoolField(TEXT("success"), true); + Result->SetStringField(TEXT("blueprint"), Blueprint); + Result->SetStringField(TEXT("interfaceName"), FoundInterface->GetName()); + Result->SetBoolField(TEXT("preservedFunctions"), PreserveFunctions); + } }; diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Mutation.cpp b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Mutation.cpp deleted file mode 100644 index 94b9f697..00000000 --- a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Mutation.cpp +++ /dev/null @@ -1,1777 +0,0 @@ -#include "MCPHandlers_Mutation.h" -#include "MCPAssetFinder.h" -#include "MCPServer.h" -#include "MCPUtils.h" -#include "Engine/Blueprint.h" -#include "Materials/Material.h" -#include "Materials/MaterialInstanceConstant.h" -#include "Materials/MaterialFunction.h" -#include "Engine/World.h" -#include "Engine/LevelScriptBlueprint.h" -#include "EdGraph/EdGraph.h" -#include "EdGraph/EdGraphNode.h" -#include "EdGraph/EdGraphPin.h" -#include "EdGraphSchema_K2.h" -#include "K2Node.h" -#include "K2Node_CallFunction.h" -#include "K2Node_Event.h" -#include "K2Node_CustomEvent.h" -#include "K2Node_FunctionEntry.h" -#include "K2Node_EditablePinBase.h" -#include "K2Node_VariableGet.h" -#include "K2Node_VariableSet.h" -#include "K2Node_BreakStruct.h" -#include "K2Node_MakeStruct.h" -#include "K2Node_DynamicCast.h" -#include "K2Node_CallParentFunction.h" -#include "K2Node_IfThenElse.h" -#include "K2Node_ExecutionSequence.h" -#include "K2Node_MacroInstance.h" -#include "K2Node_SpawnActorFromClass.h" -#include "K2Node_Select.h" -#include "K2Node_Knot.h" -#include "EdGraphNode_Comment.h" -#include "GameFramework/Actor.h" -#include "Kismet2/BlueprintEditorUtils.h" -#include "Kismet2/KismetEditorUtilities.h" -#include "Serialization/JsonReader.h" -#include "Serialization/JsonWriter.h" -#include "Serialization/JsonSerializer.h" -#include "UObject/SavePackage.h" -#include "UObject/UObjectIterator.h" -#include "Misc/PackageName.h" -#include "AssetRegistry/AssetRegistryModule.h" -#include "AssetRegistry/IAssetRegistry.h" -#include "AssetToolsModule.h" -#include "IAssetTools.h" -#include "BlueprintNodeSpawner.h" - - -// ============================================================ -// ReplaceFunctionCalls — redirect function call nodes -// ============================================================ - -void UMCPHandler_ReplaceFunctionCallsInBlueprint::Handle(const FJsonObject* Json, FJsonObject* Result) -{ - - // Load Blueprint - FString LoadError; - UBlueprint* BP = UMCPAssetFinder::LoadBlueprintByName(Blueprint, LoadError); - if (!BP) - { - return MCPUtils::MakeErrorJson(Result, LoadError); - } - - // Find the new class — try several search strategies - UClass* NewClassPtr = nullptr; - - // Try finding the class across all loaded modules - NewClassPtr = FindFirstObject(*NewClass); - - // Try with U prefix stripped/added - if (!NewClassPtr && NewClass.StartsWith(TEXT("U"))) - { - NewClassPtr = FindFirstObject(*NewClass.Mid(1)); - } - if (!NewClassPtr && !NewClass.StartsWith(TEXT("U"))) - { - NewClassPtr = FindFirstObject(*FString::Printf(TEXT("U%s"), *NewClass)); - } - - // Broader search across all modules - if (!NewClassPtr) - { - for (TObjectIterator It; It; ++It) - { - if (It->GetName() == NewClass || It->GetName() == FString::Printf(TEXT("U%s"), *NewClass)) - { - NewClassPtr = *It; - break; - } - } - } - - if (!NewClassPtr) - { - return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Could not find class '%s'"), *NewClass)); - } - - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: %s function calls in '%s': %s -> %s (%s)"), - DryRun ? TEXT("[DRY RUN] Analyzing replacement of") : TEXT("Replacing"), - *Blueprint, *OldClass, *NewClass, *NewClassPtr->GetPathName()); - - // Find all CallFunction nodes - TArray AllCallNodes; - FBlueprintEditorUtils::GetAllNodesOfClass(BP, AllCallNodes); - - int32 ReplacedCount = 0; - TArray> BrokenConnections; - - for (UK2Node_CallFunction* CallNode : AllCallNodes) - { - UClass* ParentClass = CallNode->FunctionReference.GetMemberParentClass(); - if (!ParentClass) - { - continue; - } - - // Match by class name (with or without U prefix, and _C suffix for BP classes) - FString ParentName = ParentClass->GetName(); - bool bMatch = (ParentName == OldClass) || - (ParentName == FString::Printf(TEXT("%s_C"), *OldClass)) || - (ParentName == FString::Printf(TEXT("U%s"), *OldClass)) || - (OldClass.StartsWith(TEXT("U")) && (ParentName == OldClass.Mid(1))) || - (OldClass.EndsWith(TEXT("_C")) && (ParentName == OldClass.LeftChop(2))); - - if (!bMatch) - { - continue; - } - - FName FuncName = CallNode->FunctionReference.GetMemberName(); - - // Find the matching function in the new class - UFunction* NewFunc = NewClassPtr->FindFunctionByName(FuncName); - if (!NewFunc) - { - UE_LOG(LogTemp, Warning, TEXT("BlueprintMCP: Function '%s' not found in '%s', skipping node"), - *FuncName.ToString(), *NewClass); - - TSharedRef Warning = MakeShared(); - Warning->SetStringField(TEXT("type"), TEXT("functionNotFound")); - Warning->SetStringField(TEXT("functionName"), FuncName.ToString()); - Warning->SetStringField(TEXT("nodeId"), CallNode->NodeGuid.ToString()); - BrokenConnections.Add(MakeShared(Warning)); - continue; - } - - if (DryRun) - { - // In dry run mode: report what would be affected without modifying - ReplacedCount++; - - // Check which pins have connections that might break - for (UEdGraphPin* Pin : CallNode->Pins) - { - if (!Pin || Pin->LinkedTo.Num() == 0) continue; - - // Check if the new function has a matching parameter - bool bPinExistsInNew = false; - for (TFieldIterator PropIt(NewFunc); PropIt; ++PropIt) - { - if (PropIt->GetFName() == Pin->PinName || - Pin->PinName == UEdGraphSchema_K2::PN_Execute || - Pin->PinName == UEdGraphSchema_K2::PN_Then || - Pin->PinName == UEdGraphSchema_K2::PN_Self || - Pin->PinName == UEdGraphSchema_K2::PN_ReturnValue) - { - bPinExistsInNew = true; - break; - } - } - - if (!bPinExistsInNew) - { - for (UEdGraphPin* Linked : Pin->LinkedTo) - { - if (Linked && Linked->GetOwningNode()) - { - TSharedRef AtRisk = MakeShared(); - AtRisk->SetStringField(TEXT("type"), TEXT("connectionAtRisk")); - AtRisk->SetStringField(TEXT("functionName"), FuncName.ToString()); - AtRisk->SetStringField(TEXT("nodeId"), CallNode->NodeGuid.ToString()); - AtRisk->SetStringField(TEXT("pinName"), Pin->PinName.ToString()); - AtRisk->SetStringField(TEXT("connectedToNode"), Linked->GetOwningNode()->NodeGuid.ToString()); - AtRisk->SetStringField(TEXT("connectedToPin"), Linked->PinName.ToString()); - BrokenConnections.Add(MakeShared(AtRisk)); - } - } - } - } - } - else - { - // Record existing pin connections before replacement - TMap>> OldPinConnections; - for (UEdGraphPin* Pin : CallNode->Pins) - { - if (Pin->LinkedTo.Num() > 0) - { - TArray> Links; - for (UEdGraphPin* Linked : Pin->LinkedTo) - { - if (Linked && Linked->GetOwningNode()) - { - Links.Add(TPair( - Linked->GetOwningNode()->NodeGuid.ToString(), - Linked->PinName.ToString())); - } - } - OldPinConnections.Add(Pin->PinName.ToString(), Links); - } - } - - // Replace the function reference - CallNode->SetFromFunction(NewFunc); - ReplacedCount++; - - // Check which connections survived - for (auto& Pair : OldPinConnections) - { - const FString& PinName = Pair.Key; - const TArray>& OldLinks = Pair.Value; - - UEdGraphPin* NewPin = CallNode->FindPin(FName(*PinName)); - for (auto& Link : OldLinks) - { - bool bStillConnected = false; - if (NewPin) - { - for (UEdGraphPin* L : NewPin->LinkedTo) - { - if (L && L->GetOwningNode() && - L->GetOwningNode()->NodeGuid.ToString() == Link.Key && - L->PinName.ToString() == Link.Value) - { - bStillConnected = true; - break; - } - } - } - if (!bStillConnected) - { - TSharedRef Broken = MakeShared(); - Broken->SetStringField(TEXT("type"), TEXT("connectionLost")); - Broken->SetStringField(TEXT("functionName"), FuncName.ToString()); - Broken->SetStringField(TEXT("nodeId"), CallNode->NodeGuid.ToString()); - Broken->SetStringField(TEXT("pinName"), PinName); - Broken->SetStringField(TEXT("wasConnectedToNode"), Link.Key); - Broken->SetStringField(TEXT("wasConnectedToPin"), Link.Value); - BrokenConnections.Add(MakeShared(Broken)); - } - } - } - } - } - - if (DryRun) - { - Result->SetBoolField(TEXT("dryRun"), true); - Result->SetStringField(TEXT("blueprint"), Blueprint); - Result->SetNumberField(TEXT("wouldReplaceCount"), ReplacedCount); - Result->SetNumberField(TEXT("connectionsAtRisk"), BrokenConnections.Num()); - Result->SetArrayField(TEXT("connectionsAtRisk"), BrokenConnections); - return; - } - - if (ReplacedCount > 0) - { - FBlueprintEditorUtils::MarkBlueprintAsModified(BP); - - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Replaced %d function call(s)"), ReplacedCount); - - Result->SetStringField(TEXT("blueprint"), Blueprint); - Result->SetNumberField(TEXT("replacedCount"), ReplacedCount); - Result->SetNumberField(TEXT("brokenConnectionCount"), BrokenConnections.Num()); - Result->SetArrayField(TEXT("brokenConnections"), BrokenConnections); - return; - } - - Result->SetStringField(TEXT("blueprint"), Blueprint); - Result->SetNumberField(TEXT("replacedCount"), 0); - Result->SetStringField(TEXT("message"), FString::Printf( - TEXT("No function call nodes found targeting class '%s'"), *OldClass)); -} - -// ============================================================ -// DeleteAsset — delete a .uasset after verifying no references -// ============================================================ - -void UMCPHandler_DeleteAsset::Handle(const FJsonObject* Json, FJsonObject* Result) -{ - - // Check if asset file exists on disk - FString PackageFilename = FPackageName::LongPackageNameToFilename( - AssetPath, FPackageName::GetAssetPackageExtension()); - PackageFilename = FPaths::ConvertRelativePathToFull(PackageFilename); - - if (!IFileManager::Get().FileExists(*PackageFilename)) - { - return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Asset file not found on disk: %s"), *PackageFilename)); - } - - // Check references - IAssetRegistry& Registry = *IAssetRegistry::Get(); - TArray Referencers; - Registry.GetReferencers(FName(*AssetPath), Referencers); - - // Filter out self-references - Referencers.RemoveAll([this](const FName& Ref) { - return Ref.ToString() == AssetPath; - }); - - if ((Referencers.Num() > 0) && !Force) - { - // Classify references as "live" (loaded in memory) vs "stale" (only on disk) - TArray> LiveRefs; - TArray> StaleRefs; - for (const FName& Ref : Referencers) - { - FString RefStr = Ref.ToString(); - UPackage* RefPackage = FindPackage(nullptr, *RefStr); - if (RefPackage) - { - LiveRefs.Add(MakeShared(RefStr)); - } - else - { - StaleRefs.Add(MakeShared(RefStr)); - } - } - - MCPUtils::MakeErrorJson(Result, TEXT("Asset is still referenced. Remove all references first.")); - Result->SetStringField(TEXT("assetPath"), AssetPath); - Result->SetNumberField(TEXT("referencerCount"), Referencers.Num()); - Result->SetNumberField(TEXT("liveReferencerCount"), LiveRefs.Num()); - Result->SetArrayField(TEXT("liveReferencers"), LiveRefs); - Result->SetNumberField(TEXT("staleReferencerCount"), StaleRefs.Num()); - Result->SetArrayField(TEXT("staleReferencers"), StaleRefs); - Result->SetStringField(TEXT("suggestion"), - StaleRefs.Num() > 0 - ? TEXT("Some references may be stale. Consider force=true to skip the reference check, or use change_variable_type to migrate references first.") - : TEXT("All references are live. Migrate with change_variable_type or replace_function_calls before deleting.")); - return; - } - - // Force delete: unload the package from memory first - TArray> RefWarnings; - if (Force) - { - // Collect reference warnings when force-deleting with existing references - for (const FName& Ref : Referencers) - { - RefWarnings.Add(MakeShared( - FString::Printf(TEXT("Warning: '%s' still references this asset"), *Ref.ToString()))); - } - - UPackage* Package = FindPackage(nullptr, *AssetPath); - if (Package) - { - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Force-unloading package '%s' from memory"), *AssetPath); - - // Collect all objects in this package - TArray ObjectsInPackage; - GetObjectsWithPackage(Package, ObjectsInPackage); - - // Clear flags and remove from root to allow GC - for (UObject* Obj : ObjectsInPackage) - { - if (Obj) - { - Obj->ClearFlags(RF_Standalone | RF_Public); - Obj->RemoveFromRoot(); - } - } - Package->ClearFlags(RF_Standalone | RF_Public); - Package->RemoveFromRoot(); - - // Reset loaders to release file handles - ResetLoaders(Package); - // Force garbage collection to free the objects - CollectGarbage(GARBAGE_COLLECTION_KEEPFLAGS); - } - } - - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Deleting asset '%s' (%s)%s"), - *AssetPath, *PackageFilename, Force ? TEXT(" [FORCE]") : TEXT("")); - - // Delete the file on disk - bool bDeleted = IFileManager::Get().Delete(*PackageFilename, false, true); - - if (bDeleted) - { - // Trigger an asset registry rescan so it notices the deletion - TArray PathsToScan; - int32 LastSlash; - if (AssetPath.FindLastChar(TEXT('/'), LastSlash)) - { - PathsToScan.Add(AssetPath.Left(LastSlash)); - } - if (PathsToScan.Num() > 0) - { - Registry.ScanPathsSynchronous(PathsToScan, true); - } - } - - Result->SetBoolField(TEXT("success"), bDeleted); - Result->SetStringField(TEXT("assetPath"), AssetPath); - Result->SetStringField(TEXT("filename"), PackageFilename); - Result->SetBoolField(TEXT("forced"), Force); - if (!bDeleted) - { - MCPUtils::MakeErrorJson(Result, TEXT("Failed to delete file from disk")); - } - if (RefWarnings.Num() > 0) - { - Result->SetArrayField(TEXT("warnings"), RefWarnings); - } -} - -// ============================================================ -// HandleConnectPins — wire two pins together -// ============================================================ - -// connect_pins is now handled by UMCPHandler_ConnectBlueprintPins (new-style registry) - -void UMCPHandler_ConnectBlueprintPins::Handle(const FJsonObject* Json, FJsonObject* Result) -{ - - FString LoadError; - UBlueprint* BP = UMCPAssetFinder::LoadBlueprintByName(Blueprint, LoadError); - if (!BP) - { - return MCPUtils::MakeErrorJson(Result, LoadError); - } - - TArray> Results; - int32 SuccessCount = 0; - - for (const TSharedPtr& ConnVal : Connections.Array) - { - TSharedRef EntryResult = MakeShared(); - Results.Add(MakeShared(EntryResult)); - - FConnectPinsEntry Entry; - FString PopulateError = MCPUtils::PopulateFromJson(FConnectPinsEntry::StaticStruct(), &Entry, ConnVal); - if (!PopulateError.IsEmpty()) - { - EntryResult->SetStringField(TEXT("error"), PopulateError); - continue; - } - - EntryResult->SetStringField(TEXT("sourceNodeId"), Entry.SourceNodeId); - EntryResult->SetStringField(TEXT("sourcePinName"), Entry.SourcePinName); - EntryResult->SetStringField(TEXT("targetNodeId"), Entry.TargetNodeId); - EntryResult->SetStringField(TEXT("targetPinName"), Entry.TargetPinName); - - UEdGraph* SourceGraph = nullptr; - UEdGraphNode* SourceNode = MCPUtils::FindNodeByGuid(BP, Entry.SourceNodeId, &SourceGraph); - if (!SourceNode) - { - EntryResult->SetStringField(TEXT("error"), FString::Printf(TEXT("Source node '%s' not found"), *Entry.SourceNodeId)); - continue; - } - - UEdGraphNode* TargetNode = MCPUtils::FindNodeByGuid(BP, Entry.TargetNodeId); - if (!TargetNode) - { - EntryResult->SetStringField(TEXT("error"), FString::Printf(TEXT("Target node '%s' not found"), *Entry.TargetNodeId)); - continue; - } - - UEdGraphPin* SourcePin = SourceNode->FindPin(FName(*Entry.SourcePinName)); - if (!SourcePin) - { - EntryResult->SetStringField(TEXT("error"), FString::Printf(TEXT("Source pin '%s' not found on node '%s'"), *Entry.SourcePinName, *Entry.SourceNodeId)); - continue; - } - - UEdGraphPin* TargetPin = TargetNode->FindPin(FName(*Entry.TargetPinName)); - if (!TargetPin) - { - EntryResult->SetStringField(TEXT("error"), FString::Printf(TEXT("Target pin '%s' not found on node '%s'"), *Entry.TargetPinName, *Entry.TargetNodeId)); - continue; - } - - const UEdGraphSchema* Schema = SourceGraph->GetSchema(); - if (!Schema) - { - EntryResult->SetStringField(TEXT("error"), TEXT("Graph schema not found")); - continue; - } - - bool bConnected = Schema->TryCreateConnection(SourcePin, TargetPin); - if (!bConnected) - { - EntryResult->SetStringField(TEXT("error"), FString::Printf( - TEXT("Cannot connect %s (%s) to %s (%s) — types are incompatible"), - *Entry.SourcePinName, *SourcePin->PinType.PinCategory.ToString(), - *Entry.TargetPinName, *TargetPin->PinType.PinCategory.ToString())); - continue; - } - - EntryResult->SetBoolField(TEXT("success"), true); - SuccessCount++; - } - - if (SuccessCount > 0) - { - FBlueprintEditorUtils::MarkBlueprintAsModified(BP); - } - - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: ConnectPins — %d/%d succeeded in '%s'"), - SuccessCount, Connections.Array.Num(), *Blueprint); - - Result->SetBoolField(TEXT("success"), true); - Result->SetStringField(TEXT("blueprint"), Blueprint); - Result->SetNumberField(TEXT("successCount"), SuccessCount); - Result->SetNumberField(TEXT("totalCount"), Connections.Array.Num()); - Result->SetArrayField(TEXT("results"), Results); -} - -// ============================================================ -// HandleDisconnectPin — break connections on a pin -// ============================================================ - -// disconnect_pin is now handled by UMCPHandler_DisconnectBlueprintPins (new-style registry) - -void UMCPHandler_DisconnectBlueprintPins::Handle(const FJsonObject* Json, FJsonObject* Result) -{ - - FString LoadError; - UBlueprint* BP = UMCPAssetFinder::LoadBlueprintByName(Blueprint, LoadError); - if (!BP) - { - return MCPUtils::MakeErrorJson(Result, LoadError); - } - - TArray> Results; - int32 SuccessCount = 0; - int32 TotalDisconnected = 0; - - for (const TSharedPtr& DiscVal : Disconnections.Array) - { - TSharedRef EntryResult = MakeShared(); - Results.Add(MakeShared(EntryResult)); - - FDisconnectPinEntry Entry; - FString PopulateError = MCPUtils::PopulateFromJson(FDisconnectPinEntry::StaticStruct(), &Entry, DiscVal); - if (!PopulateError.IsEmpty()) - { - EntryResult->SetStringField(TEXT("error"), PopulateError); - continue; - } - - EntryResult->SetStringField(TEXT("nodeId"), Entry.NodeId); - EntryResult->SetStringField(TEXT("pinName"), Entry.PinName); - - UEdGraphNode* Node = MCPUtils::FindNodeByGuid(BP, Entry.NodeId); - if (!Node) - { - EntryResult->SetStringField(TEXT("error"), FString::Printf(TEXT("Node '%s' not found"), *Entry.NodeId)); - continue; - } - - UEdGraphPin* Pin = Node->FindPin(FName(*Entry.PinName)); - if (!Pin) - { - EntryResult->SetStringField(TEXT("error"), FString::Printf(TEXT("Pin '%s' not found on node '%s'"), *Entry.PinName, *Entry.NodeId)); - continue; - } - - int32 DisconnectedCount = 0; - - if (!Entry.TargetNodeId.IsEmpty() && !Entry.TargetPinName.IsEmpty()) - { - UEdGraphNode* TargetNode = MCPUtils::FindNodeByGuid(BP, Entry.TargetNodeId); - if (!TargetNode) - { - EntryResult->SetStringField(TEXT("error"), FString::Printf(TEXT("Target node '%s' not found"), *Entry.TargetNodeId)); - continue; - } - - UEdGraphPin* TargetPin = TargetNode->FindPin(FName(*Entry.TargetPinName)); - if (!TargetPin) - { - EntryResult->SetStringField(TEXT("error"), FString::Printf(TEXT("Target pin '%s' not found on node '%s'"), *Entry.TargetPinName, *Entry.TargetNodeId)); - continue; - } - - if (!Pin->LinkedTo.Contains(TargetPin)) - { - EntryResult->SetStringField(TEXT("error"), TEXT("The specified pins are not connected to each other")); - continue; - } - - Pin->BreakLinkTo(TargetPin); - DisconnectedCount = 1; - } - else - { - DisconnectedCount = Pin->LinkedTo.Num(); - if (DisconnectedCount > 0) - { - Pin->BreakAllPinLinks(true); - } - } - - EntryResult->SetBoolField(TEXT("success"), true); - EntryResult->SetNumberField(TEXT("disconnectedCount"), DisconnectedCount); - SuccessCount++; - TotalDisconnected += DisconnectedCount; - } - - if (TotalDisconnected > 0) - { - FBlueprintEditorUtils::MarkBlueprintAsModified(BP); - } - - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: DisconnectPin — %d/%d succeeded, %d links broken in '%s'"), - SuccessCount, Disconnections.Array.Num(), TotalDisconnected, *Blueprint); - - Result->SetBoolField(TEXT("success"), true); - Result->SetStringField(TEXT("blueprint"), Blueprint); - Result->SetNumberField(TEXT("successCount"), SuccessCount); - Result->SetNumberField(TEXT("totalCount"), Disconnections.Array.Num()); - Result->SetNumberField(TEXT("totalDisconnected"), TotalDisconnected); - Result->SetArrayField(TEXT("results"), Results); -} - -// ============================================================ -// RefreshAllNodes — refresh all nodes and recompile -// ============================================================ - -void UMCPHandler_RefreshAllNodesInGraph::Handle(const FJsonObject* Json, FJsonObject* Result) -{ - - // Load Blueprint - FString LoadError; - UBlueprint* BP = UMCPAssetFinder::LoadBlueprintByName(Blueprint, LoadError); - if (!BP) - { - return MCPUtils::MakeErrorJson(Result, LoadError); - } - - // Count graphs and nodes before refresh - TArray AllGraphs; - BP->GetAllGraphs(AllGraphs); - int32 GraphCount = AllGraphs.Num(); - int32 NodeCount = 0; - for (UEdGraph* G : AllGraphs) - { - if (G) NodeCount += G->Nodes.Num(); - } - - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Refreshing all nodes in '%s' (%d graphs, %d nodes)"), - *Blueprint, GraphCount, NodeCount); - - // Refresh all nodes - FBlueprintEditorUtils::RefreshAllNodes(BP); - - // Remove orphaned pins from all nodes - int32 OrphanedPinsRemoved = 0; - for (UEdGraph* G : AllGraphs) - { - if (!G) continue; - for (UEdGraphNode* Node : G->Nodes) - { - if (!Node) continue; - for (int32 i = Node->Pins.Num() - 1; i >= 0; --i) - { - UEdGraphPin* Pin = Node->Pins[i]; - if (Pin && Pin->bOrphanedPin) - { - Pin->BreakAllPinLinks(); - Node->Pins.RemoveAt(i); - OrphanedPinsRemoved++; - } - } - } - } - - if (OrphanedPinsRemoved > 0) - { - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Removed %d orphaned pins"), OrphanedPinsRemoved); - } - - // Mark as modified and recompile after orphan removal - FBlueprintEditorUtils::MarkBlueprintAsStructurallyModified(BP); - - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: RefreshAllNodes complete")); - - // Collect compiler warnings and errors from the blueprint status - TArray> WarningsArr; - TArray> ErrorsArr; - - if (BP->Status == BS_Error) - { - ErrorsArr.Add(MakeShared(TEXT("Blueprint has compiler errors after refresh"))); - } - - // Check each graph for nodes with error/warning status - AllGraphs.Empty(); - BP->GetAllGraphs(AllGraphs); - for (UEdGraph* G : AllGraphs) - { - if (!G) continue; - for (UEdGraphNode* Node : G->Nodes) - { - if (!Node) continue; - if (Node->bHasCompilerMessage) - { - FString NodeTitle = Node->GetNodeTitle(ENodeTitleType::FullTitle).ToString(); - FString NodeMsg = FString::Printf(TEXT("[%s] %s: %s"), - *G->GetName(), *NodeTitle, *Node->ErrorMsg); - if (Node->ErrorType == EMessageSeverity::Error) - { - ErrorsArr.Add(MakeShared(NodeMsg)); - } - else - { - WarningsArr.Add(MakeShared(NodeMsg)); - } - } - } - } - - Result->SetBoolField(TEXT("success"), true); - Result->SetStringField(TEXT("blueprint"), Blueprint); - Result->SetNumberField(TEXT("graphCount"), GraphCount); - Result->SetNumberField(TEXT("nodeCount"), NodeCount); - Result->SetNumberField(TEXT("orphanedPinsRemoved"), OrphanedPinsRemoved); - Result->SetArrayField(TEXT("warnings"), WarningsArr); - Result->SetArrayField(TEXT("errors"), ErrorsArr); -} - -// ============================================================ -// SetPinDefault — set the default value of a pin on a node -// ============================================================ - -void UMCPHandler_SetPinDefaultValues::Handle(const FJsonObject* Json, FJsonObject* Result) -{ - - TArray> Results; - int32 SuccessCount = 0; - TSet ModifiedNodes; - TSet ModifiedBlueprints; - - for (const TSharedPtr& PinVal : Pins.Array) - { - TSharedRef EntryResult = MakeShared(); - Results.Add(MakeShared(EntryResult)); - - FSetPinDefaultEntry Entry; - FString PopulateError = MCPUtils::PopulateFromJson(FSetPinDefaultEntry::StaticStruct(), &Entry, PinVal); - if (!PopulateError.IsEmpty()) - { - EntryResult->SetStringField(TEXT("error"), PopulateError); - continue; - } - - EntryResult->SetStringField(TEXT("blueprint"), Entry.Blueprint); - EntryResult->SetStringField(TEXT("nodeId"), Entry.NodeId); - EntryResult->SetStringField(TEXT("pinName"), Entry.PinName); - - FString LoadError; - UBlueprint* BP = UMCPAssetFinder::LoadBlueprintByName(Entry.Blueprint, LoadError); - if (!BP) - { - EntryResult->SetStringField(TEXT("error"), LoadError); - continue; - } - - UEdGraph* Graph = nullptr; - UEdGraphNode* Node = MCPUtils::FindNodeByGuid(BP, Entry.NodeId, &Graph); - if (!Node) - { - EntryResult->SetStringField(TEXT("error"), FString::Printf(TEXT("Node '%s' not found"), *Entry.NodeId)); - continue; - } - - UEdGraphPin* Pin = Node->FindPin(FName(*Entry.PinName)); - if (!Pin) - { - EntryResult->SetStringField(TEXT("error"), FString::Printf(TEXT("Pin '%s' not found on node '%s'"), *Entry.PinName, *Entry.NodeId)); - continue; - } - - if (Pin->Direction != EGPD_Input) - { - EntryResult->SetStringField(TEXT("error"), FString::Printf(TEXT("Pin '%s' is an output pin"), *Entry.PinName)); - continue; - } - - const UEdGraphSchema* Schema = Graph->GetSchema(); - if (Schema) - { - FString ValidationError = Schema->IsPinDefaultValid(Pin, Entry.Value, nullptr, FText::GetEmpty()); - if (!ValidationError.IsEmpty()) - { - EntryResult->SetStringField(TEXT("error"), FString::Printf( - TEXT("Invalid value for pin '%s': %s"), *Entry.PinName, *ValidationError)); - continue; - } - } - - FString OldValue = Pin->DefaultValue; - Pin->DefaultValue = Entry.Value; - - EntryResult->SetBoolField(TEXT("success"), true); - EntryResult->SetStringField(TEXT("oldValue"), OldValue); - EntryResult->SetStringField(TEXT("newValue"), Pin->DefaultValue); - SuccessCount++; - ModifiedNodes.Add(Node); - ModifiedBlueprints.Add(BP); - } - - for (UEdGraphNode* Node : ModifiedNodes) - { - Node->ReconstructNode(); - } - - for (UBlueprint* BP : ModifiedBlueprints) - { - FBlueprintEditorUtils::MarkBlueprintAsModified(BP); - } - - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: SetPinDefault — %d/%d succeeded"), - SuccessCount, Pins.Array.Num()); - - Result->SetBoolField(TEXT("success"), true); - Result->SetNumberField(TEXT("successCount"), SuccessCount); - Result->SetNumberField(TEXT("totalCount"), Pins.Array.Num()); - Result->SetArrayField(TEXT("results"), Results); -} - -// ============================================================ -// ChangeStructNodeType — change the struct type on a Break/Make node -// ============================================================ - -void UMCPHandler_ChangeStructNodeType::Handle(const FJsonObject* Json, FJsonObject* Result) -{ - - // Load Blueprint - FString LoadError; - UBlueprint* BP = UMCPAssetFinder::LoadBlueprintByName(Blueprint, LoadError); - if (!BP) - { - return MCPUtils::MakeErrorJson(Result, LoadError); - } - - // Find node - UEdGraph* Graph = nullptr; - UEdGraphNode* Node = MCPUtils::FindNodeByGuid(BP, NodeId, &Graph); - if (!Node) - { - return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Node '%s' not found"), *NodeId)); - } - - // Determine what kind of struct node this is - UK2Node_BreakStruct* BreakNode = Cast(Node); - UK2Node_MakeStruct* MakeNode = Cast(Node); - - if (!BreakNode && !MakeNode) - { - return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Node '%s' is not a BreakStruct or MakeStruct node (class: %s)"), - *NodeId, *Node->GetClass()->GetName())); - } - - // Find the new struct type - FString SearchName = NewType; - if (SearchName.StartsWith(TEXT("F"))) - { - SearchName = SearchName.Mid(1); - } - - UScriptStruct* NewStruct = FindFirstObject(*SearchName); - if (!NewStruct) - { - // Try with full name including F prefix - NewStruct = FindFirstObject(*NewType); - } - if (!NewStruct) - { - return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Struct type '%s' not found"), *NewType)); - } - - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Changing struct node '%s' to type '%s'"), - *NodeId, *NewStruct->GetName()); - - // Helper: extract property base name from a BreakStruct pin name - auto ExtractPropertyBaseName = [](const FString& PinName) -> FString - { - // Find the last underscore before 32 hex chars (GUID) - int32 LastUnderscore; - if (PinName.FindLastChar(TEXT('_'), LastUnderscore) && (LastUnderscore > 0)) - { - FString Suffix = PinName.Mid(LastUnderscore + 1); - if (Suffix.Len() == 32) - { - FString WithoutGuid = PinName.Left(LastUnderscore); - // Strip _Index - int32 SecondUnderscore; - if (WithoutGuid.FindLastChar(TEXT('_'), SecondUnderscore) && (SecondUnderscore > 0)) - { - FString IndexStr = WithoutGuid.Mid(SecondUnderscore + 1); - if (IndexStr.IsNumeric()) - { - return WithoutGuid.Left(SecondUnderscore); - } - } - } - } - return PinName; - }; - - // Remember existing connections keyed by property base name - struct FPinConnection - { - EEdGraphPinDirection Direction; - TArray LinkedPins; - }; - TMap ConnectionsByBaseName; - - for (UEdGraphPin* Pin : Node->Pins) - { - if (!Pin || Pin->LinkedTo.Num() == 0) continue; - if (Pin->PinType.PinCategory == UEdGraphSchema_K2::PC_Exec) continue; - - FString BaseName = ExtractPropertyBaseName(Pin->PinName.ToString()); - FPinConnection& Conn = ConnectionsByBaseName.FindOrAdd(BaseName); - Conn.Direction = Pin->Direction; - Conn.LinkedPins = Pin->LinkedTo; - } - - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Saved %d pin connections to reconnect"), ConnectionsByBaseName.Num()); - - // Change the struct type and reconstruct - if (BreakNode) - { - BreakNode->StructType = NewStruct; - } - else if (MakeNode) - { - MakeNode->StructType = NewStruct; - } - - // Break all existing links before reconstruction - Node->BreakAllNodeLinks(); - - // Reconnect pins by matching property base names - const UEdGraphSchema* Schema = Graph->GetSchema(); - if (!Schema) - { - return MCPUtils::MakeErrorJson(Result, TEXT("Graph schema not found")); - } - - // Reconstruct to rebuild pins for the new struct type (use schema version for MinimalAPI compat) - Schema->ReconstructNode(*Node); - - int32 Reconnected = 0; - int32 Failed = 0; - TArray> ReconnectDetails; - - for (auto& Pair : ConnectionsByBaseName) - { - const FString& BaseName = Pair.Key; - const FPinConnection& OldConn = Pair.Value; - - // Find matching new pin - UEdGraphPin* NewPin = nullptr; - for (UEdGraphPin* Pin : Node->Pins) - { - if (!Pin || Pin->Direction != OldConn.Direction) continue; - FString NewBaseName = ExtractPropertyBaseName(Pin->PinName.ToString()); - if (NewBaseName.Equals(BaseName, ESearchCase::IgnoreCase)) - { - NewPin = Pin; - break; - } - } - - // Also try matching the struct input/output pin (single struct pin) - if (!NewPin) - { - for (UEdGraphPin* Pin : Node->Pins) - { - if (!Pin || Pin->Direction != OldConn.Direction) continue; - if ((Pin->PinType.PinCategory == UEdGraphSchema_K2::PC_Struct) && - (Pin->PinType.PinSubCategoryObject == NewStruct)) - { - NewPin = Pin; - break; - } - } - } - - if (NewPin) - { - for (UEdGraphPin* Target : OldConn.LinkedPins) - { - bool bOK = Schema->TryCreateConnection(NewPin, Target); - if (bOK) - { - Reconnected++; - } - else - { - Failed++; - } - - TSharedPtr Detail = MakeShared(); - Detail->SetStringField(TEXT("property"), BaseName); - Detail->SetBoolField(TEXT("connected"), bOK); - ReconnectDetails.Add(MakeShared(Detail)); - } - } - else - { - Failed += OldConn.LinkedPins.Num(); - TSharedPtr Detail = MakeShared(); - Detail->SetStringField(TEXT("property"), BaseName); - Detail->SetBoolField(TEXT("connected"), false); - Detail->SetStringField(TEXT("reason"), TEXT("No matching pin found on new struct")); - ReconnectDetails.Add(MakeShared(Detail)); - } - } - - FBlueprintEditorUtils::MarkBlueprintAsStructurallyModified(BP); - - // Return updated node state - TSharedPtr UpdatedNodeState = MCPUtils::SerializeNode(Node); - - Result->SetBoolField(TEXT("success"), true); - Result->SetStringField(TEXT("blueprint"), Blueprint); - Result->SetStringField(TEXT("nodeId"), NodeId); - Result->SetStringField(TEXT("newStructType"), NewStruct->GetName()); - Result->SetStringField(TEXT("nodeClass"), Node->GetClass()->GetName()); - Result->SetNumberField(TEXT("reconnected"), Reconnected); - Result->SetNumberField(TEXT("failed"), Failed); - Result->SetArrayField(TEXT("reconnectDetails"), ReconnectDetails); - if (UpdatedNodeState.IsValid()) - { - Result->SetObjectField(TEXT("updatedNode"), UpdatedNodeState); - } -} - -// ============================================================ -// HandleDeleteNode — remove a node from a blueprint graph -// ============================================================ - -// delete_node is now handled by UMCPHandler_DeleteNodeFromGraph (new-style registry) - -void UMCPHandler_DeleteNodeFromGraph::Handle(const FJsonObject* Json, FJsonObject* Result) -{ - - FString LoadError; - UBlueprint* BP = UMCPAssetFinder::LoadBlueprintByName(Blueprint, LoadError); - if (!BP) - { - return MCPUtils::MakeErrorJson(Result, LoadError); - } - - UEdGraph* Graph = nullptr; - UEdGraphNode* Node = MCPUtils::FindNodeByGuid(BP, NodeId, &Graph); - if (!Node) - { - return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Node '%s' not found"), *NodeId)); - } - if (!Graph) - { - return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Graph not found for node '%s'"), *NodeId)); - } - - FString NodeClass = Node->GetClass()->GetName(); - FString NodeTitle = Node->GetNodeTitle(ENodeTitleType::FullTitle).ToString(); - FString GraphName = Graph->GetName(); - - // Protect root/entry nodes — deleting these leaves the graph in an invalid - // state with no root node, causing compiler errors that can't be fixed - // without recreating the entire function/event. - if (Cast(Node)) - { - return MCPUtils::MakeErrorJson(Result, FString::Printf( - TEXT("Cannot delete FunctionEntry node '%s' in graph '%s'. ") - TEXT("This is the root node of the function — removing it would leave an empty, uncompilable graph. ") - TEXT("To remove the entire function, delete it from the Blueprint editor."), - *NodeTitle, *GraphName)); - } - if (Cast(Node)) - { - return MCPUtils::MakeErrorJson(Result, FString::Printf( - TEXT("Cannot delete event entry node '%s' in graph '%s'. ") - TEXT("This is the root node of the event handler — removing it would leave an empty, uncompilable graph."), - *NodeTitle, *GraphName)); - } - if (Cast(Node)) - { - return MCPUtils::MakeErrorJson(Result, FString::Printf( - TEXT("Cannot delete CustomEvent entry node '%s' in graph '%s'. ") - TEXT("This is the root node of the custom event — removing it would leave an empty, uncompilable graph."), - *NodeTitle, *GraphName)); - } - - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Deleting node '%s' (%s) from graph '%s' in '%s'"), - *NodeId, *NodeTitle, *GraphName, *Blueprint); - - Node->BreakAllNodeLinks(); - Graph->RemoveNode(Node); - - FBlueprintEditorUtils::MarkBlueprintAsStructurallyModified(BP); - - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Node deleted")); - - Result->SetBoolField(TEXT("success"), true); - Result->SetStringField(TEXT("blueprint"), Blueprint); - Result->SetStringField(TEXT("nodeId"), NodeId); - Result->SetStringField(TEXT("nodeClass"), NodeClass); - Result->SetStringField(TEXT("nodeTitle"), NodeTitle); - Result->SetStringField(TEXT("graph"), GraphName); -} - -// (add_node endpoint removed — use spawn_node instead) - -// ============================================================ -// RenameAsset — rename or move an asset -// ============================================================ - -void UMCPHandler_RenameAsset::Handle(const FJsonObject* Json, FJsonObject* Result) -{ - - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Renaming asset '%s' -> '%s'"), *AssetPath, *NewPath); - - // Use FAssetToolsModule to perform the rename with reference fixup - FAssetToolsModule& AssetToolsModule = FModuleManager::LoadModuleChecked("AssetTools"); - IAssetTools& AssetTools = AssetToolsModule.Get(); - - // Build the source/dest arrays - TArray RenameData; - - // We need to load the asset to get the object - FAssetData* FoundAsset = UMCPAssetFinder::FindAnyAsset(AssetPath); - if (!FoundAsset) - { - return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Asset '%s' not found. Checked Blueprints, Materials, Material Instances, and Material Functions."), *AssetPath)); - } - - UObject* AssetObj = FoundAsset->GetAsset(); - if (!AssetObj) - { - return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Failed to load asset '%s'"), *AssetPath)); - } - - // Parse new path into package path and asset name - FString NewPackagePath, NewAssetName; - int32 LastSlash; - if (NewPath.FindLastChar(TEXT('/'), LastSlash)) - { - NewPackagePath = NewPath.Left(LastSlash); - NewAssetName = NewPath.Mid(LastSlash + 1); - } - else - { - // If no slash, assume same directory with new name - FString OldPackagePath; - if (AssetPath.FindLastChar(TEXT('/'), LastSlash)) - { - OldPackagePath = AssetPath.Left(LastSlash); - } - NewPackagePath = OldPackagePath; - NewAssetName = NewPath; - } - - FAssetRenameData RenameEntry(AssetObj, NewPackagePath, NewAssetName); - RenameData.Add(RenameEntry); - - bool bSuccess = AssetTools.RenameAssets(RenameData); - - if (bSuccess) - { - } - - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Rename %s"), bSuccess ? TEXT("succeeded") : TEXT("failed")); - - Result->SetBoolField(TEXT("success"), bSuccess); - Result->SetStringField(TEXT("oldPath"), AssetPath); - Result->SetStringField(TEXT("newPath"), NewPath); - Result->SetStringField(TEXT("newPackagePath"), NewPackagePath); - Result->SetStringField(TEXT("newAssetName"), NewAssetName); - if (!bSuccess) - { - MCPUtils::MakeErrorJson(Result, TEXT("Asset rename failed. The target path may be invalid or a conflicting asset may exist.")); - } -} - -// ============================================================ -// SetBlueprintDefault — set a default property value on a Blueprint CDO -// ============================================================ - -void UMCPHandler_SetClassDefaultValue::Handle(const FJsonObject* Json, FJsonObject* Result) -{ - - // Load Blueprint - FString LoadError; - UBlueprint* BP = UMCPAssetFinder::LoadBlueprintByName(Blueprint, LoadError); - if (!BP) - { - return MCPUtils::MakeErrorJson(Result, LoadError); - } - - if (!BP->GeneratedClass) - { - return MCPUtils::MakeErrorJson(Result, TEXT("Blueprint has no GeneratedClass")); - } - - UObject* CDO = BP->GeneratedClass->GetDefaultObject(); - if (!CDO) - { - return MCPUtils::MakeErrorJson(Result, TEXT("Could not get Class Default Object")); - } - - FProperty* Prop = BP->GeneratedClass->FindPropertyByName(*Property); - if (!Prop) - { - return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Property '%s' not found on '%s'"), *Property, *Blueprint)); - } - - FString OldValue; - Prop->ExportTextItem_Direct(OldValue, Prop->ContainerPtrToValuePtr(CDO), nullptr, CDO, PPF_None); - - bool bSuccess = false; - FString ActualNewValue; - - // Handle class/soft-class properties (TSubclassOf, TSoftClassPtr) - FClassProperty* ClassProp = CastField(Prop); - FSoftClassProperty* SoftClassProp = CastField(Prop); - - if (ClassProp || SoftClassProp) - { - // Resolve the value to a UClass* - UClass* ResolvedClass = nullptr; - - // Try as a C++ class name first - for (TObjectIterator It; It; ++It) - { - if (It->GetName() == Value || It->GetName() == Value + TEXT("_C")) - { - ResolvedClass = *It; - break; - } - } - - // Try loading as a Blueprint asset - if (!ResolvedClass) - { - FString BPLoadError; - UBlueprint* ValueBP = UMCPAssetFinder::LoadBlueprintByName(Value, BPLoadError); - if (ValueBP && ValueBP->GeneratedClass) - { - ResolvedClass = ValueBP->GeneratedClass; - } - } - - if (!ResolvedClass) - { - return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Could not resolve '%s' to a class"), *Value)); - } - - // Validate meta class compatibility - if (ClassProp) - { - UClass* MetaClass = ClassProp->MetaClass; - if (MetaClass && !ResolvedClass->IsChildOf(MetaClass)) - { - return MCPUtils::MakeErrorJson(Result, FString::Printf( - TEXT("'%s' is not a subclass of '%s' (required by property '%s')"), - *ResolvedClass->GetName(), *MetaClass->GetName(), *Property)); - } - ClassProp->SetPropertyValue_InContainer(CDO, ResolvedClass); - } - else - { - FSoftObjectPtr SoftPtr(ResolvedClass); - SoftClassProp->SetPropertyValue_InContainer(CDO, SoftPtr); - } - ActualNewValue = ResolvedClass->GetName(); - bSuccess = true; - } - // Handle object properties (TObjectPtr, UObject*) - else if (FObjectProperty* ObjProp = CastField(Prop)) - { - // Try finding an existing object/asset by name - UObject* ResolvedObj = nullptr; - - // Try loading as a Blueprint asset - FString ObjLoadError; - UBlueprint* ValueBP = UMCPAssetFinder::LoadBlueprintByName(Value, ObjLoadError); - if (ValueBP && ValueBP->GeneratedClass) - { - ResolvedObj = ValueBP->GeneratedClass->GetDefaultObject(); - } - - if (!ResolvedObj) - { - return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Could not resolve '%s' to an object"), *Value)); - } - - ObjProp->SetPropertyValue_InContainer(CDO, ResolvedObj); - ActualNewValue = ResolvedObj->GetName(); - bSuccess = true; - } - // Handle simple types via ImportText - else - { - const TCHAR* ImportResult = Prop->ImportText_Direct(*Value, Prop->ContainerPtrToValuePtr(CDO), CDO, PPF_None); - if (ImportResult) - { - Prop->ExportTextItem_Direct(ActualNewValue, Prop->ContainerPtrToValuePtr(CDO), nullptr, CDO, PPF_None); - bSuccess = true; - } - else - { - return MCPUtils::MakeErrorJson(Result, FString::Printf( - TEXT("Failed to set property '%s' to '%s' — value could not be parsed for type '%s'"), - *Property, *Value, *Prop->GetCPPType())); - } - } - - if (!bSuccess) - { - return MCPUtils::MakeErrorJson(Result, TEXT("Failed to set property value")); - } - - // Mark modified and save - CDO->MarkPackageDirty(); - BP->Modify(); - - FKismetEditorUtilities::CompileBlueprint(BP); - bool bSaved = MCPUtils::SaveBlueprintPackage(BP); - - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Set '%s.%s' from '%s' to '%s' (saved: %s)"), - *Blueprint, *Property, *OldValue, *ActualNewValue, bSaved ? TEXT("true") : TEXT("false")); - - Result->SetBoolField(TEXT("success"), true); - Result->SetStringField(TEXT("blueprint"), Blueprint); - Result->SetStringField(TEXT("property"), Property); - Result->SetStringField(TEXT("oldValue"), OldValue); - Result->SetStringField(TEXT("newValue"), ActualNewValue); - Result->SetStringField(TEXT("propertyType"), Prop->GetCPPType()); - Result->SetBoolField(TEXT("saved"), bSaved); -} - -// ============================================================ -// MoveNode — reposition one or more nodes in a blueprint graph -// ============================================================ - -void UMCPHandler_SetNodePositions::Handle(const FJsonObject* Json, FJsonObject* Result) -{ - - FString LoadError; - UBlueprint* BP = UMCPAssetFinder::LoadBlueprintByName(Blueprint, LoadError); - if (!BP) - { - return MCPUtils::MakeErrorJson(Result, LoadError); - } - - TArray> Results; - int32 SuccessCount = 0; - - for (const TSharedPtr& NodeVal : Nodes.Array) - { - TSharedRef EntryResult = MakeShared(); - Results.Add(MakeShared(EntryResult)); - - FMoveNodeEntry Entry; - FString PopulateError = MCPUtils::PopulateFromJson(FMoveNodeEntry::StaticStruct(), &Entry, NodeVal); - if (!PopulateError.IsEmpty()) - { - EntryResult->SetStringField(TEXT("error"), PopulateError); - continue; - } - - EntryResult->SetStringField(TEXT("nodeId"), Entry.NodeId); - - UEdGraphNode* Node = MCPUtils::FindNodeByGuid(BP, Entry.NodeId); - if (!Node) - { - EntryResult->SetStringField(TEXT("error"), FString::Printf(TEXT("Node '%s' not found"), *Entry.NodeId)); - continue; - } - - int32 OldX = Node->NodePosX; - int32 OldY = Node->NodePosY; - Node->NodePosX = Entry.X; - Node->NodePosY = Entry.Y; - EntryResult->SetBoolField(TEXT("success"), true); - EntryResult->SetNumberField(TEXT("oldX"), OldX); - EntryResult->SetNumberField(TEXT("oldY"), OldY); - EntryResult->SetNumberField(TEXT("newX"), Node->NodePosX); - EntryResult->SetNumberField(TEXT("newY"), Node->NodePosY); - SuccessCount++; - } - - FBlueprintEditorUtils::MarkBlueprintAsModified(BP); - - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: MoveNode — %d/%d succeeded"), - SuccessCount, Nodes.Array.Num()); - - Result->SetBoolField(TEXT("success"), true); - Result->SetStringField(TEXT("blueprint"), Blueprint); - Result->SetNumberField(TEXT("movedCount"), SuccessCount); - Result->SetNumberField(TEXT("totalRequested"), Nodes.Array.Num()); - Result->SetArrayField(TEXT("results"), Results); -} - -// ============================================================ -// DuplicateNodes — duplicate one or more nodes in a graph -// ============================================================ - -void UMCPHandler_DuplicateNodesInGraph::Handle(const FJsonObject* Json, FJsonObject* Result) -{ - - FString LoadError; - UBlueprint* BP = UMCPAssetFinder::LoadBlueprintByName(Blueprint, LoadError); - if (!BP) - { - return MCPUtils::MakeErrorJson(Result, LoadError); - } - - // Find the target graph - FString DecodedGraphName = MCPUtils::UrlDecode(Graph); - UEdGraph* TargetGraph = nullptr; - TArray AllGraphs; - BP->GetAllGraphs(AllGraphs); - - for (UEdGraph* G : AllGraphs) - { - if (G && G->GetName().Equals(DecodedGraphName, ESearchCase::IgnoreCase)) - { - TargetGraph = G; - break; - } - } - - if (!TargetGraph) - { - return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Graph '%s' not found"), *DecodedGraphName)); - } - - if (NodeIds.Array.Num() == 0) - { - return MCPUtils::MakeErrorJson(Result, TEXT("nodeIds array is empty")); - } - - // Find all source nodes - TArray SourceNodes; - TArray NotFound; - - for (const TSharedPtr& IdVal : NodeIds.Array) - { - FString NodeId = IdVal->AsString(); - UEdGraphNode* Node = MCPUtils::FindNodeByGuid(BP, NodeId); - if (Node) - { - if (Node->GetGraph() == TargetGraph) - { - SourceNodes.Add(Node); - } - else - { - NotFound.Add(FString::Printf(TEXT("%s (in different graph)"), *NodeId)); - } - } - else - { - NotFound.Add(NodeId); - } - } - - if (SourceNodes.Num() == 0) - { - return MCPUtils::MakeErrorJson(Result, TEXT("No valid nodes found to duplicate")); - } - - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Duplicating %d node(s) in graph '%s' of '%s'"), - SourceNodes.Num(), *DecodedGraphName, *Blueprint); - - // Duplicate each node - TArray> DuplicatedNodes; - TMap OldToNewGuidMap; - - for (UEdGraphNode* SourceNode : SourceNodes) - { - UEdGraphNode* NewNode = DuplicateObject(SourceNode, TargetGraph); - if (!NewNode) - { - TSharedRef Entry = MakeShared(); - Entry->SetStringField(TEXT("sourceNodeId"), SourceNode->NodeGuid.ToString()); - Entry->SetStringField(TEXT("error"), TEXT("DuplicateObject failed")); - DuplicatedNodes.Add(MakeShared(Entry)); - continue; - } - - NewNode->CreateNewGuid(); - OldToNewGuidMap.Add(SourceNode->NodeGuid, NewNode->NodeGuid); - - NewNode->NodePosX += OffsetX; - NewNode->NodePosY += OffsetY; - - for (UEdGraphPin* Pin : NewNode->Pins) - { - if (Pin) - { - Pin->LinkedTo.Empty(); - } - } - - TargetGraph->AddNode(NewNode, false, false); - - TSharedRef Entry = MakeShared(); - Entry->SetStringField(TEXT("sourceNodeId"), SourceNode->NodeGuid.ToString()); - Entry->SetStringField(TEXT("newNodeId"), NewNode->NodeGuid.ToString()); - Entry->SetNumberField(TEXT("posX"), NewNode->NodePosX); - Entry->SetNumberField(TEXT("posY"), NewNode->NodePosY); - Entry->SetStringField(TEXT("nodeClass"), NewNode->GetClass()->GetName()); - Entry->SetStringField(TEXT("nodeTitle"), NewNode->GetNodeTitle(ENodeTitleType::FullTitle).ToString()); - DuplicatedNodes.Add(MakeShared(Entry)); - } - - FBlueprintEditorUtils::MarkBlueprintAsStructurallyModified(BP); - - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Duplicated %d node(s)"), - DuplicatedNodes.Num()); - - Result->SetBoolField(TEXT("success"), true); - Result->SetStringField(TEXT("blueprint"), Blueprint); - Result->SetStringField(TEXT("graph"), DecodedGraphName); - Result->SetNumberField(TEXT("duplicatedCount"), DuplicatedNodes.Num()); - Result->SetArrayField(TEXT("nodes"), DuplicatedNodes); - - if (NotFound.Num() > 0) - { - TArray> NotFoundArr; - for (const FString& NF : NotFound) - { - NotFoundArr.Add(MakeShared(NF)); - } - Result->SetArrayField(TEXT("notFound"), NotFoundArr); - } -} - -// ============================================================ -// HandleGetNodeComment — read a node's comment text -// ============================================================ - -// get_node_comment is now handled by UMCPHandler_GetNodeComment (new-style registry) - -void UMCPHandler_GetNodeComment::Handle(const FJsonObject* Json, FJsonObject* Result) -{ - - FString LoadError; - UBlueprint* BP = UMCPAssetFinder::LoadBlueprintByName(Blueprint, LoadError); - if (!BP) - { - return MCPUtils::MakeErrorJson(Result, LoadError); - } - - UEdGraphNode* Node = MCPUtils::FindNodeByGuid(BP, NodeId); - if (!Node) - { - return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Node '%s' not found"), *NodeId)); - } - - Result->SetBoolField(TEXT("success"), true); - Result->SetStringField(TEXT("blueprint"), Blueprint); - Result->SetStringField(TEXT("nodeId"), NodeId); - Result->SetStringField(TEXT("comment"), Node->NodeComment); - Result->SetBoolField(TEXT("commentBubbleVisible"), Node->bCommentBubbleVisible); -} - -// ============================================================ -// HandleSetNodeComment — set a node's comment text -// ============================================================ - -// set_node_comment is now handled by UMCPHandler_SetNodeComment (new-style registry) - -void UMCPHandler_SetNodeComment::Handle(const FJsonObject* Json, FJsonObject* Result) -{ - - FString LoadError; - UBlueprint* BP = UMCPAssetFinder::LoadBlueprintByName(Blueprint, LoadError); - if (!BP) - { - return MCPUtils::MakeErrorJson(Result, LoadError); - } - - UEdGraphNode* Node = MCPUtils::FindNodeByGuid(BP, NodeId); - if (!Node) - { - return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Node '%s' not found"), *NodeId)); - } - - FString OldComment = Node->NodeComment; - Node->NodeComment = Comment; - - // Make the comment bubble visible if setting a non-empty comment - if (!Comment.IsEmpty()) - { - Node->bCommentBubbleVisible = true; - Node->bCommentBubblePinned = true; - } - - FBlueprintEditorUtils::MarkBlueprintAsModified(BP); - - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Set comment on node '%s' in '%s'"), - *NodeId, *Blueprint); - - Result->SetBoolField(TEXT("success"), true); - Result->SetStringField(TEXT("blueprint"), Blueprint); - Result->SetStringField(TEXT("nodeId"), NodeId); - Result->SetStringField(TEXT("oldComment"), OldComment); - Result->SetStringField(TEXT("newComment"), Comment); -} - -// ============================================================ -// Shared helper: iterate the blueprint action database. -// ============================================================ -// SearchNodeTypes — search the blueprint action database -// for spawners matching a query string (same pool as the right-click menu) -// ============================================================ - -void UMCPHandler_SearchSpawnableNodeTypes::Handle(const FJsonObject* Json, FJsonObject* Result) -{ - int32 ClampedMax = FMath::Clamp(MaxResults, 1, 500); - - TArray Spawners = MCPUtils::SearchNodeSpawners(Query, ClampedMax); - - TArray> ResultArray; - for (UBlueprintNodeSpawner* Spawner : Spawners) - { - ResultArray.Add(MakeShared(MCPUtils::NodeSpawnerFullName(Spawner))); - } - - Result->SetBoolField(TEXT("success"), true); - Result->SetNumberField(TEXT("count"), ResultArray.Num()); - Result->SetArrayField(TEXT("results"), ResultArray); -} - -// ============================================================ -// HandleSpawnNode — create a node using the action database spawner system. -// Takes a full action name, finds the spawner, and calls Invoke(). -// ============================================================ - -void UMCPHandler_SpawnNodesInGraph::Handle(const FJsonObject* Json, FJsonObject* Result) -{ - - // Load Blueprint - FString LoadError; - UBlueprint* BP = UMCPAssetFinder::LoadBlueprintByName(Blueprint, LoadError); - if (!BP) - { - return MCPUtils::MakeErrorJson(Result, LoadError); - } - - // Find the target graph - FString DecodedGraphName = MCPUtils::UrlDecode(Graph); - UEdGraph* TargetGraph = nullptr; - TArray AllGraphs; - BP->GetAllGraphs(AllGraphs); - - for (UEdGraph* G : AllGraphs) - { - if (G && G->GetName().Equals(DecodedGraphName, ESearchCase::IgnoreCase)) - { - TargetGraph = G; - break; - } - } - - if (!TargetGraph) - { - TArray> GraphNames; - for (UEdGraph* G : AllGraphs) - { - if (G) GraphNames.Add(MakeShared(G->GetName())); - } - MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Graph '%s' not found"), *DecodedGraphName)); - Result->SetArrayField(TEXT("availableGraphs"), GraphNames); - return; - } - - TArray> Results; - int32 SuccessCount = 0; - - for (const TSharedPtr& NodeVal : Nodes.Array) - { - TSharedRef EntryResult = MakeShared(); - Results.Add(MakeShared(EntryResult)); - - FSpawnNodeEntry Entry; - FString PopulateError = MCPUtils::PopulateFromJson(FSpawnNodeEntry::StaticStruct(), &Entry, NodeVal); - if (!PopulateError.IsEmpty()) - { - EntryResult->SetStringField(TEXT("error"), PopulateError); - continue; - } - - EntryResult->SetStringField(TEXT("actionName"), Entry.ActionName); - - // Find the spawner by exact full name - TArray Matches = MCPUtils::SearchNodeSpawners(Entry.ActionName, 0, /*ExactMatch=*/true); - if (Matches.Num() == 0) - { - EntryResult->SetStringField(TEXT("error"), FString::Printf( - TEXT("No action found matching '%s'. Use search_node_types to find available actions."), - *Entry.ActionName)); - continue; - } - if (Matches.Num() > 1) - { - EntryResult->SetStringField(TEXT("error"), FString::Printf( - TEXT("Ambiguous: %d spawners match '%s'. Cannot determine which one to use."), - Matches.Num(), *Entry.ActionName)); - continue; - } - UBlueprintNodeSpawner* Spawner = Matches[0]; - - // Invoke the spawner - FVector2D Location(Entry.PosX, Entry.PosY); - IBlueprintNodeBinder::FBindingSet Bindings; - UEdGraphNode* NewNode = Spawner->Invoke(TargetGraph, Bindings, Location); - - if (!NewNode) - { - EntryResult->SetStringField(TEXT("error"), TEXT("Spawner Invoke() returned null — node creation failed.")); - continue; - } - - // Ensure valid GUID - if (!NewNode->NodeGuid.IsValid()) - { - NewNode->CreateNewGuid(); - } - - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Spawned node '%s' (class %s) via action '%s' in graph '%s' of '%s'"), - *NewNode->NodeGuid.ToString(), - *NewNode->GetClass()->GetName(), - *Entry.ActionName, - *DecodedGraphName, - *Blueprint); - - // Serialize result - TSharedPtr NodeState = MCPUtils::SerializeNode(NewNode); - - EntryResult->SetBoolField(TEXT("success"), true); - EntryResult->SetStringField(TEXT("nodeId"), NewNode->NodeGuid.ToString()); - EntryResult->SetStringField(TEXT("nodeClass"), NewNode->GetClass()->GetName()); - EntryResult->SetStringField(TEXT("nodeTitle"), NewNode->GetNodeTitle(ENodeTitleType::ListView).ToString()); - if (NodeState.IsValid()) - { - EntryResult->SetObjectField(TEXT("node"), NodeState); - } - SuccessCount++; - } - - FBlueprintEditorUtils::MarkBlueprintAsStructurallyModified(BP); - - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: SpawnNode — %d/%d succeeded in graph '%s' of '%s'"), - SuccessCount, Nodes.Array.Num(), *DecodedGraphName, *Blueprint); - - Result->SetBoolField(TEXT("success"), true); - Result->SetStringField(TEXT("blueprint"), Blueprint); - Result->SetStringField(TEXT("graph"), DecodedGraphName); - Result->SetNumberField(TEXT("successCount"), SuccessCount); - Result->SetNumberField(TEXT("totalCount"), Nodes.Array.Num()); - Result->SetArrayField(TEXT("results"), Results); -} - diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Mutation.h b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Mutation.h index 5d557122..7d5dd919 100644 --- a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Mutation.h +++ b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Mutation.h @@ -2,42 +2,57 @@ #include "CoreMinimal.h" #include "MCPHandler.h" +#include "MCPAssetFinder.h" +#include "MCPServer.h" +#include "MCPUtils.h" +#include "Engine/Blueprint.h" +#include "Materials/Material.h" +#include "Materials/MaterialInstanceConstant.h" +#include "Materials/MaterialFunction.h" +#include "Engine/World.h" +#include "Engine/LevelScriptBlueprint.h" +#include "EdGraph/EdGraph.h" +#include "EdGraph/EdGraphNode.h" +#include "EdGraph/EdGraphPin.h" +#include "EdGraphSchema_K2.h" +#include "K2Node.h" +#include "K2Node_CallFunction.h" +#include "K2Node_Event.h" +#include "K2Node_CustomEvent.h" +#include "K2Node_FunctionEntry.h" +#include "K2Node_EditablePinBase.h" +#include "K2Node_VariableGet.h" +#include "K2Node_VariableSet.h" +#include "K2Node_BreakStruct.h" +#include "K2Node_MakeStruct.h" +#include "K2Node_DynamicCast.h" +#include "K2Node_CallParentFunction.h" +#include "K2Node_IfThenElse.h" +#include "K2Node_ExecutionSequence.h" +#include "K2Node_MacroInstance.h" +#include "K2Node_SpawnActorFromClass.h" +#include "K2Node_Select.h" +#include "K2Node_Knot.h" +#include "EdGraphNode_Comment.h" +#include "GameFramework/Actor.h" +#include "Kismet2/BlueprintEditorUtils.h" +#include "Kismet2/KismetEditorUtilities.h" +#include "Serialization/JsonReader.h" +#include "Serialization/JsonWriter.h" +#include "Serialization/JsonSerializer.h" +#include "UObject/SavePackage.h" +#include "UObject/UObjectIterator.h" +#include "Misc/PackageName.h" +#include "AssetRegistry/AssetRegistryModule.h" +#include "AssetRegistry/IAssetRegistry.h" +#include "AssetToolsModule.h" +#include "IAssetTools.h" +#include "BlueprintNodeSpawner.h" #include "MCPHandlers_Mutation.generated.h" -USTRUCT() -struct FSetPinDefaultEntry -{ - GENERATED_BODY() - - UPROPERTY() - FString Blueprint; - - UPROPERTY() - FString NodeId; - - UPROPERTY() - FString PinName; - - UPROPERTY() - FString Value; -}; - -UCLASS(meta=(ToolName="set_pin_default_values")) -class UMCPHandler_SetPinDefaultValues : public UObject, public IMCPHandler -{ - GENERATED_BODY() - -public: - UPROPERTY(meta=(Description="Array of {blueprint, nodeId, pinName, value} objects")) - FMCPJsonArray Pins; - - virtual FString GetDescription() const override - { - return TEXT("Set the default value of input pins on nodes."); - } - - virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override; -}; +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- USTRUCT() struct FMoveNodeEntry @@ -54,6 +69,7 @@ struct FMoveNodeEntry int32 Y = 0; }; + UCLASS(meta=(ToolName="set_node_positions")) class UMCPHandler_SetNodePositions : public UObject, public IMCPHandler { @@ -71,9 +87,70 @@ public: return TEXT("Reposition one or more nodes in a Blueprint graph."); } - virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override; + virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override + { + + FString LoadError; + UBlueprint* BP = UMCPAssetFinder::LoadBlueprintByName(Blueprint, LoadError); + if (!BP) + { + return MCPUtils::MakeErrorJson(Result, LoadError); + } + + TArray> Results; + int32 SuccessCount = 0; + + for (const TSharedPtr& NodeVal : Nodes.Array) + { + TSharedRef EntryResult = MakeShared(); + Results.Add(MakeShared(EntryResult)); + + FMoveNodeEntry Entry; + FString PopulateError = MCPUtils::PopulateFromJson(FMoveNodeEntry::StaticStruct(), &Entry, NodeVal); + if (!PopulateError.IsEmpty()) + { + EntryResult->SetStringField(TEXT("error"), PopulateError); + continue; + } + + EntryResult->SetStringField(TEXT("nodeId"), Entry.NodeId); + + UEdGraphNode* Node = MCPUtils::FindNodeByGuid(BP, Entry.NodeId); + if (!Node) + { + EntryResult->SetStringField(TEXT("error"), FString::Printf(TEXT("Node '%s' not found"), *Entry.NodeId)); + continue; + } + + int32 OldX = Node->NodePosX; + int32 OldY = Node->NodePosY; + Node->NodePosX = Entry.X; + Node->NodePosY = Entry.Y; + EntryResult->SetBoolField(TEXT("success"), true); + EntryResult->SetNumberField(TEXT("oldX"), OldX); + EntryResult->SetNumberField(TEXT("oldY"), OldY); + EntryResult->SetNumberField(TEXT("newX"), Node->NodePosX); + EntryResult->SetNumberField(TEXT("newY"), Node->NodePosY); + SuccessCount++; + } + + FBlueprintEditorUtils::MarkBlueprintAsModified(BP); + + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: MoveNode — %d/%d succeeded"), + SuccessCount, Nodes.Array.Num()); + + Result->SetBoolField(TEXT("success"), true); + Result->SetStringField(TEXT("blueprint"), Blueprint); + Result->SetNumberField(TEXT("movedCount"), SuccessCount); + Result->SetNumberField(TEXT("totalRequested"), Nodes.Array.Num()); + Result->SetArrayField(TEXT("results"), Results); + } }; +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + UCLASS(meta=(ToolName="duplicate_nodes_in_graph")) class UMCPHandler_DuplicateNodesInGraph : public UObject, public IMCPHandler { @@ -102,9 +179,143 @@ public: "Connections are not preserved on the duplicates."); } - virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override; + virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override + { + + FString LoadError; + UBlueprint* BP = UMCPAssetFinder::LoadBlueprintByName(Blueprint, LoadError); + if (!BP) + { + return MCPUtils::MakeErrorJson(Result, LoadError); + } + + // Find the target graph + FString DecodedGraphName = MCPUtils::UrlDecode(Graph); + UEdGraph* TargetGraph = nullptr; + TArray AllGraphs; + BP->GetAllGraphs(AllGraphs); + + for (UEdGraph* G : AllGraphs) + { + if (G && G->GetName().Equals(DecodedGraphName, ESearchCase::IgnoreCase)) + { + TargetGraph = G; + break; + } + } + + if (!TargetGraph) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Graph '%s' not found"), *DecodedGraphName)); + } + + if (NodeIds.Array.Num() == 0) + { + return MCPUtils::MakeErrorJson(Result, TEXT("nodeIds array is empty")); + } + + // Find all source nodes + TArray SourceNodes; + TArray NotFound; + + for (const TSharedPtr& IdVal : NodeIds.Array) + { + FString NodeId = IdVal->AsString(); + UEdGraphNode* Node = MCPUtils::FindNodeByGuid(BP, NodeId); + if (Node) + { + if (Node->GetGraph() == TargetGraph) + { + SourceNodes.Add(Node); + } + else + { + NotFound.Add(FString::Printf(TEXT("%s (in different graph)"), *NodeId)); + } + } + else + { + NotFound.Add(NodeId); + } + } + + if (SourceNodes.Num() == 0) + { + return MCPUtils::MakeErrorJson(Result, TEXT("No valid nodes found to duplicate")); + } + + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Duplicating %d node(s) in graph '%s' of '%s'"), + SourceNodes.Num(), *DecodedGraphName, *Blueprint); + + // Duplicate each node + TArray> DuplicatedNodes; + TMap OldToNewGuidMap; + + for (UEdGraphNode* SourceNode : SourceNodes) + { + UEdGraphNode* NewNode = DuplicateObject(SourceNode, TargetGraph); + if (!NewNode) + { + TSharedRef Entry = MakeShared(); + Entry->SetStringField(TEXT("sourceNodeId"), SourceNode->NodeGuid.ToString()); + Entry->SetStringField(TEXT("error"), TEXT("DuplicateObject failed")); + DuplicatedNodes.Add(MakeShared(Entry)); + continue; + } + + NewNode->CreateNewGuid(); + OldToNewGuidMap.Add(SourceNode->NodeGuid, NewNode->NodeGuid); + + NewNode->NodePosX += OffsetX; + NewNode->NodePosY += OffsetY; + + for (UEdGraphPin* Pin : NewNode->Pins) + { + if (Pin) + { + Pin->LinkedTo.Empty(); + } + } + + TargetGraph->AddNode(NewNode, false, false); + + TSharedRef Entry = MakeShared(); + Entry->SetStringField(TEXT("sourceNodeId"), SourceNode->NodeGuid.ToString()); + Entry->SetStringField(TEXT("newNodeId"), NewNode->NodeGuid.ToString()); + Entry->SetNumberField(TEXT("posX"), NewNode->NodePosX); + Entry->SetNumberField(TEXT("posY"), NewNode->NodePosY); + Entry->SetStringField(TEXT("nodeClass"), NewNode->GetClass()->GetName()); + Entry->SetStringField(TEXT("nodeTitle"), NewNode->GetNodeTitle(ENodeTitleType::FullTitle).ToString()); + DuplicatedNodes.Add(MakeShared(Entry)); + } + + FBlueprintEditorUtils::MarkBlueprintAsStructurallyModified(BP); + + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Duplicated %d node(s)"), + DuplicatedNodes.Num()); + + Result->SetBoolField(TEXT("success"), true); + Result->SetStringField(TEXT("blueprint"), Blueprint); + Result->SetStringField(TEXT("graph"), DecodedGraphName); + Result->SetNumberField(TEXT("duplicatedCount"), DuplicatedNodes.Num()); + Result->SetArrayField(TEXT("nodes"), DuplicatedNodes); + + if (NotFound.Num() > 0) + { + TArray> NotFoundArr; + for (const FString& NF : NotFound) + { + NotFoundArr.Add(MakeShared(NF)); + } + Result->SetArrayField(TEXT("notFound"), NotFoundArr); + } + } }; +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + USTRUCT() struct FSpawnNodeEntry { @@ -120,6 +331,7 @@ struct FSpawnNodeEntry int32 PosY = 0; }; + UCLASS(meta=(ToolName="spawn_nodes_in_graph")) class UMCPHandler_SpawnNodesInGraph : public UObject, public IMCPHandler { @@ -142,9 +354,136 @@ public: "Use search_node_types first to find the exact action name."); } - virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override; + virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override + { + + // Load Blueprint + FString LoadError; + UBlueprint* BP = UMCPAssetFinder::LoadBlueprintByName(Blueprint, LoadError); + if (!BP) + { + return MCPUtils::MakeErrorJson(Result, LoadError); + } + + // Find the target graph + FString DecodedGraphName = MCPUtils::UrlDecode(Graph); + UEdGraph* TargetGraph = nullptr; + TArray AllGraphs; + BP->GetAllGraphs(AllGraphs); + + for (UEdGraph* G : AllGraphs) + { + if (G && G->GetName().Equals(DecodedGraphName, ESearchCase::IgnoreCase)) + { + TargetGraph = G; + break; + } + } + + if (!TargetGraph) + { + TArray> GraphNames; + for (UEdGraph* G : AllGraphs) + { + if (G) GraphNames.Add(MakeShared(G->GetName())); + } + MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Graph '%s' not found"), *DecodedGraphName)); + Result->SetArrayField(TEXT("availableGraphs"), GraphNames); + return; + } + + TArray> Results; + int32 SuccessCount = 0; + + for (const TSharedPtr& NodeVal : Nodes.Array) + { + TSharedRef EntryResult = MakeShared(); + Results.Add(MakeShared(EntryResult)); + + FSpawnNodeEntry Entry; + FString PopulateError = MCPUtils::PopulateFromJson(FSpawnNodeEntry::StaticStruct(), &Entry, NodeVal); + if (!PopulateError.IsEmpty()) + { + EntryResult->SetStringField(TEXT("error"), PopulateError); + continue; + } + + EntryResult->SetStringField(TEXT("actionName"), Entry.ActionName); + + // Find the spawner by exact full name + TArray Matches = MCPUtils::SearchNodeSpawners(Entry.ActionName, 0, /*ExactMatch=*/true); + if (Matches.Num() == 0) + { + EntryResult->SetStringField(TEXT("error"), FString::Printf( + TEXT("No action found matching '%s'. Use search_node_types to find available actions."), + *Entry.ActionName)); + continue; + } + if (Matches.Num() > 1) + { + EntryResult->SetStringField(TEXT("error"), FString::Printf( + TEXT("Ambiguous: %d spawners match '%s'. Cannot determine which one to use."), + Matches.Num(), *Entry.ActionName)); + continue; + } + UBlueprintNodeSpawner* Spawner = Matches[0]; + + // Invoke the spawner + FVector2D Location(Entry.PosX, Entry.PosY); + IBlueprintNodeBinder::FBindingSet Bindings; + UEdGraphNode* NewNode = Spawner->Invoke(TargetGraph, Bindings, Location); + + if (!NewNode) + { + EntryResult->SetStringField(TEXT("error"), TEXT("Spawner Invoke() returned null — node creation failed.")); + continue; + } + + // Ensure valid GUID + if (!NewNode->NodeGuid.IsValid()) + { + NewNode->CreateNewGuid(); + } + + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Spawned node '%s' (class %s) via action '%s' in graph '%s' of '%s'"), + *NewNode->NodeGuid.ToString(), + *NewNode->GetClass()->GetName(), + *Entry.ActionName, + *DecodedGraphName, + *Blueprint); + + // Serialize result + TSharedPtr NodeState = MCPUtils::SerializeNode(NewNode); + + EntryResult->SetBoolField(TEXT("success"), true); + EntryResult->SetStringField(TEXT("nodeId"), NewNode->NodeGuid.ToString()); + EntryResult->SetStringField(TEXT("nodeClass"), NewNode->GetClass()->GetName()); + EntryResult->SetStringField(TEXT("nodeTitle"), NewNode->GetNodeTitle(ENodeTitleType::ListView).ToString()); + if (NodeState.IsValid()) + { + EntryResult->SetObjectField(TEXT("node"), NodeState); + } + SuccessCount++; + } + + FBlueprintEditorUtils::MarkBlueprintAsStructurallyModified(BP); + + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: SpawnNode — %d/%d succeeded in graph '%s' of '%s'"), + SuccessCount, Nodes.Array.Num(), *DecodedGraphName, *Blueprint); + + Result->SetBoolField(TEXT("success"), true); + Result->SetStringField(TEXT("blueprint"), Blueprint); + Result->SetStringField(TEXT("graph"), DecodedGraphName); + Result->SetNumberField(TEXT("successCount"), SuccessCount); + Result->SetNumberField(TEXT("totalCount"), Nodes.Array.Num()); + Result->SetArrayField(TEXT("results"), Results); + } }; +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + UCLASS(meta=(ToolName="get_node_comment")) class UMCPHandler_GetNodeComment : public UObject, public IMCPHandler { @@ -162,9 +501,34 @@ public: return TEXT("Get the comment text and bubble visibility of a node."); } - virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override; + virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override + { + + FString LoadError; + UBlueprint* BP = UMCPAssetFinder::LoadBlueprintByName(Blueprint, LoadError); + if (!BP) + { + return MCPUtils::MakeErrorJson(Result, LoadError); + } + + UEdGraphNode* Node = MCPUtils::FindNodeByGuid(BP, NodeId); + if (!Node) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Node '%s' not found"), *NodeId)); + } + + Result->SetBoolField(TEXT("success"), true); + Result->SetStringField(TEXT("blueprint"), Blueprint); + Result->SetStringField(TEXT("nodeId"), NodeId); + Result->SetStringField(TEXT("comment"), Node->NodeComment); + Result->SetBoolField(TEXT("commentBubbleVisible"), Node->bCommentBubbleVisible); + } }; +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + UCLASS(meta=(ToolName="set_node_comment")) class UMCPHandler_SetNodeComment : public UObject, public IMCPHandler { @@ -185,9 +549,49 @@ public: return TEXT("Set a node's comment text. Makes the comment bubble visible if non-empty."); } - virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override; + virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override + { + + FString LoadError; + UBlueprint* BP = UMCPAssetFinder::LoadBlueprintByName(Blueprint, LoadError); + if (!BP) + { + return MCPUtils::MakeErrorJson(Result, LoadError); + } + + UEdGraphNode* Node = MCPUtils::FindNodeByGuid(BP, NodeId); + if (!Node) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Node '%s' not found"), *NodeId)); + } + + FString OldComment = Node->NodeComment; + Node->NodeComment = Comment; + + // Make the comment bubble visible if setting a non-empty comment + if (!Comment.IsEmpty()) + { + Node->bCommentBubbleVisible = true; + Node->bCommentBubblePinned = true; + } + + FBlueprintEditorUtils::MarkBlueprintAsModified(BP); + + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Set comment on node '%s' in '%s'"), + *NodeId, *Blueprint); + + Result->SetBoolField(TEXT("success"), true); + Result->SetStringField(TEXT("blueprint"), Blueprint); + Result->SetStringField(TEXT("nodeId"), NodeId); + Result->SetStringField(TEXT("oldComment"), OldComment); + Result->SetStringField(TEXT("newComment"), Comment); + } }; +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + UCLASS(meta=(ToolName="delete_node_from_graph")) class UMCPHandler_DeleteNodeFromGraph : public UObject, public IMCPHandler { @@ -206,85 +610,79 @@ public: "Cannot delete entry nodes (FunctionEntry, Event, CustomEvent)."); } - virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override; -}; - -USTRUCT() -struct FConnectPinsEntry -{ - GENERATED_BODY() - - UPROPERTY() - FString SourceNodeId; - - UPROPERTY() - FString SourcePinName; - - UPROPERTY() - FString TargetNodeId; - - UPROPERTY() - FString TargetPinName; -}; - -UCLASS(meta=(ToolName="connect_blueprint_pins")) -class UMCPHandler_ConnectBlueprintPins : public UObject, public IMCPHandler -{ - GENERATED_BODY() - -public: - UPROPERTY(meta=(Description="Blueprint name or package path")) - FString Blueprint; - - UPROPERTY(meta=(Description="Array of {sourceNodeId, sourcePinName, targetNodeId, targetPinName} objects")) - FMCPJsonArray Connections; - - virtual FString GetDescription() const override + virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override { - return TEXT("Connect pins between nodes in a Blueprint graph."); + + FString LoadError; + UBlueprint* BP = UMCPAssetFinder::LoadBlueprintByName(Blueprint, LoadError); + if (!BP) + { + return MCPUtils::MakeErrorJson(Result, LoadError); + } + + UEdGraph* Graph = nullptr; + UEdGraphNode* Node = MCPUtils::FindNodeByGuid(BP, NodeId, &Graph); + if (!Node) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Node '%s' not found"), *NodeId)); + } + if (!Graph) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Graph not found for node '%s'"), *NodeId)); + } + + FString NodeClass = Node->GetClass()->GetName(); + FString NodeTitle = Node->GetNodeTitle(ENodeTitleType::FullTitle).ToString(); + FString GraphName = Graph->GetName(); + + // Protect root/entry nodes — deleting these leaves the graph in an invalid + // state with no root node, causing compiler errors that can't be fixed + // without recreating the entire function/event. + if (Cast(Node)) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf( + TEXT("Cannot delete FunctionEntry node '%s' in graph '%s'. ") + TEXT("This is the root node of the function — removing it would leave an empty, uncompilable graph. ") + TEXT("To remove the entire function, delete it from the Blueprint editor."), + *NodeTitle, *GraphName)); + } + if (Cast(Node)) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf( + TEXT("Cannot delete event entry node '%s' in graph '%s'. ") + TEXT("This is the root node of the event handler — removing it would leave an empty, uncompilable graph."), + *NodeTitle, *GraphName)); + } + if (Cast(Node)) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf( + TEXT("Cannot delete CustomEvent entry node '%s' in graph '%s'. ") + TEXT("This is the root node of the custom event — removing it would leave an empty, uncompilable graph."), + *NodeTitle, *GraphName)); + } + + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Deleting node '%s' (%s) from graph '%s' in '%s'"), + *NodeId, *NodeTitle, *GraphName, *Blueprint); + + Node->BreakAllNodeLinks(); + Graph->RemoveNode(Node); + + FBlueprintEditorUtils::MarkBlueprintAsStructurallyModified(BP); + + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Node deleted")); + + Result->SetBoolField(TEXT("success"), true); + Result->SetStringField(TEXT("blueprint"), Blueprint); + Result->SetStringField(TEXT("nodeId"), NodeId); + Result->SetStringField(TEXT("nodeClass"), NodeClass); + Result->SetStringField(TEXT("nodeTitle"), NodeTitle); + Result->SetStringField(TEXT("graph"), GraphName); } - - virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override; }; -USTRUCT() -struct FDisconnectPinEntry -{ - GENERATED_BODY() - - UPROPERTY() - FString NodeId; - - UPROPERTY() - FString PinName; - - UPROPERTY(meta=(Optional)) - FString TargetNodeId; - - UPROPERTY(meta=(Optional)) - FString TargetPinName; -}; - -UCLASS(meta=(ToolName="disconnect_blueprint_pins")) -class UMCPHandler_DisconnectBlueprintPins : public UObject, public IMCPHandler -{ - GENERATED_BODY() - -public: - UPROPERTY(meta=(Description="Blueprint name or package path")) - FString Blueprint; - - UPROPERTY(meta=(Description="Array of {nodeId, pinName, targetNodeId?, targetPinName?} objects. If target is omitted, all connections on the pin are broken.")) - FMCPJsonArray Disconnections; - - virtual FString GetDescription() const override - { - return TEXT("Disconnect pins in a Blueprint graph. " - "Can disconnect a specific link or all links on a pin."); - } - - virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override; -}; +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- UCLASS(meta=(ToolName="replace_function_calls_in_blueprint")) class UMCPHandler_ReplaceFunctionCallsInBlueprint : public UObject, public IMCPHandler @@ -310,30 +708,243 @@ public: "Supports dry-run to preview impact before applying."); } - virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override; -}; - -UCLASS(meta=(ToolName="delete_asset")) -class UMCPHandler_DeleteAsset : public UObject, public IMCPHandler -{ - GENERATED_BODY() - -public: - UPROPERTY(meta=(Description="Package path of the asset to delete")) - FString AssetPath; - - UPROPERTY(meta=(Optional, Description="If true, skip reference check and force delete")) - bool Force = false; - - virtual FString GetDescription() const override + virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override { - return TEXT("Delete a .uasset after verifying no references. " - "Use force=true to skip the reference check."); - } - virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override; + // Load Blueprint + FString LoadError; + UBlueprint* BP = UMCPAssetFinder::LoadBlueprintByName(Blueprint, LoadError); + if (!BP) + { + return MCPUtils::MakeErrorJson(Result, LoadError); + } + + // Find the new class — try several search strategies + UClass* NewClassPtr = nullptr; + + // Try finding the class across all loaded modules + NewClassPtr = FindFirstObject(*NewClass); + + // Try with U prefix stripped/added + if (!NewClassPtr && NewClass.StartsWith(TEXT("U"))) + { + NewClassPtr = FindFirstObject(*NewClass.Mid(1)); + } + if (!NewClassPtr && !NewClass.StartsWith(TEXT("U"))) + { + NewClassPtr = FindFirstObject(*FString::Printf(TEXT("U%s"), *NewClass)); + } + + // Broader search across all modules + if (!NewClassPtr) + { + for (TObjectIterator It; It; ++It) + { + if (It->GetName() == NewClass || It->GetName() == FString::Printf(TEXT("U%s"), *NewClass)) + { + NewClassPtr = *It; + break; + } + } + } + + if (!NewClassPtr) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Could not find class '%s'"), *NewClass)); + } + + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: %s function calls in '%s': %s -> %s (%s)"), + DryRun ? TEXT("[DRY RUN] Analyzing replacement of") : TEXT("Replacing"), + *Blueprint, *OldClass, *NewClass, *NewClassPtr->GetPathName()); + + // Find all CallFunction nodes + TArray AllCallNodes; + FBlueprintEditorUtils::GetAllNodesOfClass(BP, AllCallNodes); + + int32 ReplacedCount = 0; + TArray> BrokenConnections; + + for (UK2Node_CallFunction* CallNode : AllCallNodes) + { + UClass* ParentClass = CallNode->FunctionReference.GetMemberParentClass(); + if (!ParentClass) + { + continue; + } + + // Match by class name (with or without U prefix, and _C suffix for BP classes) + FString ParentName = ParentClass->GetName(); + bool bMatch = (ParentName == OldClass) || + (ParentName == FString::Printf(TEXT("%s_C"), *OldClass)) || + (ParentName == FString::Printf(TEXT("U%s"), *OldClass)) || + (OldClass.StartsWith(TEXT("U")) && (ParentName == OldClass.Mid(1))) || + (OldClass.EndsWith(TEXT("_C")) && (ParentName == OldClass.LeftChop(2))); + + if (!bMatch) + { + continue; + } + + FName FuncName = CallNode->FunctionReference.GetMemberName(); + + // Find the matching function in the new class + UFunction* NewFunc = NewClassPtr->FindFunctionByName(FuncName); + if (!NewFunc) + { + UE_LOG(LogTemp, Warning, TEXT("BlueprintMCP: Function '%s' not found in '%s', skipping node"), + *FuncName.ToString(), *NewClass); + + TSharedRef Warning = MakeShared(); + Warning->SetStringField(TEXT("type"), TEXT("functionNotFound")); + Warning->SetStringField(TEXT("functionName"), FuncName.ToString()); + Warning->SetStringField(TEXT("nodeId"), CallNode->NodeGuid.ToString()); + BrokenConnections.Add(MakeShared(Warning)); + continue; + } + + if (DryRun) + { + // In dry run mode: report what would be affected without modifying + ReplacedCount++; + + // Check which pins have connections that might break + for (UEdGraphPin* Pin : CallNode->Pins) + { + if (!Pin || Pin->LinkedTo.Num() == 0) continue; + + // Check if the new function has a matching parameter + bool bPinExistsInNew = false; + for (TFieldIterator PropIt(NewFunc); PropIt; ++PropIt) + { + if (PropIt->GetFName() == Pin->PinName || + Pin->PinName == UEdGraphSchema_K2::PN_Execute || + Pin->PinName == UEdGraphSchema_K2::PN_Then || + Pin->PinName == UEdGraphSchema_K2::PN_Self || + Pin->PinName == UEdGraphSchema_K2::PN_ReturnValue) + { + bPinExistsInNew = true; + break; + } + } + + if (!bPinExistsInNew) + { + for (UEdGraphPin* Linked : Pin->LinkedTo) + { + if (Linked && Linked->GetOwningNode()) + { + TSharedRef AtRisk = MakeShared(); + AtRisk->SetStringField(TEXT("type"), TEXT("connectionAtRisk")); + AtRisk->SetStringField(TEXT("functionName"), FuncName.ToString()); + AtRisk->SetStringField(TEXT("nodeId"), CallNode->NodeGuid.ToString()); + AtRisk->SetStringField(TEXT("pinName"), Pin->PinName.ToString()); + AtRisk->SetStringField(TEXT("connectedToNode"), Linked->GetOwningNode()->NodeGuid.ToString()); + AtRisk->SetStringField(TEXT("connectedToPin"), Linked->PinName.ToString()); + BrokenConnections.Add(MakeShared(AtRisk)); + } + } + } + } + } + else + { + // Record existing pin connections before replacement + TMap>> OldPinConnections; + for (UEdGraphPin* Pin : CallNode->Pins) + { + if (Pin->LinkedTo.Num() > 0) + { + TArray> Links; + for (UEdGraphPin* Linked : Pin->LinkedTo) + { + if (Linked && Linked->GetOwningNode()) + { + Links.Add(TPair( + Linked->GetOwningNode()->NodeGuid.ToString(), + Linked->PinName.ToString())); + } + } + OldPinConnections.Add(Pin->PinName.ToString(), Links); + } + } + + // Replace the function reference + CallNode->SetFromFunction(NewFunc); + ReplacedCount++; + + // Check which connections survived + for (auto& Pair : OldPinConnections) + { + const FString& PinName = Pair.Key; + const TArray>& OldLinks = Pair.Value; + + UEdGraphPin* NewPin = CallNode->FindPin(FName(*PinName)); + for (auto& Link : OldLinks) + { + bool bStillConnected = false; + if (NewPin) + { + for (UEdGraphPin* L : NewPin->LinkedTo) + { + if (L && L->GetOwningNode() && + L->GetOwningNode()->NodeGuid.ToString() == Link.Key && + L->PinName.ToString() == Link.Value) + { + bStillConnected = true; + break; + } + } + } + if (!bStillConnected) + { + TSharedRef Broken = MakeShared(); + Broken->SetStringField(TEXT("type"), TEXT("connectionLost")); + Broken->SetStringField(TEXT("functionName"), FuncName.ToString()); + Broken->SetStringField(TEXT("nodeId"), CallNode->NodeGuid.ToString()); + Broken->SetStringField(TEXT("pinName"), PinName); + Broken->SetStringField(TEXT("wasConnectedToNode"), Link.Key); + Broken->SetStringField(TEXT("wasConnectedToPin"), Link.Value); + BrokenConnections.Add(MakeShared(Broken)); + } + } + } + } + } + + if (DryRun) + { + Result->SetBoolField(TEXT("dryRun"), true); + Result->SetStringField(TEXT("blueprint"), Blueprint); + Result->SetNumberField(TEXT("wouldReplaceCount"), ReplacedCount); + Result->SetNumberField(TEXT("connectionsAtRisk"), BrokenConnections.Num()); + Result->SetArrayField(TEXT("connectionsAtRisk"), BrokenConnections); + return; + } + + if (ReplacedCount > 0) + { + FBlueprintEditorUtils::MarkBlueprintAsModified(BP); + + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Replaced %d function call(s)"), ReplacedCount); + + Result->SetStringField(TEXT("blueprint"), Blueprint); + Result->SetNumberField(TEXT("replacedCount"), ReplacedCount); + Result->SetNumberField(TEXT("brokenConnectionCount"), BrokenConnections.Num()); + Result->SetArrayField(TEXT("brokenConnections"), BrokenConnections); + return; + } + + Result->SetStringField(TEXT("blueprint"), Blueprint); + Result->SetNumberField(TEXT("replacedCount"), 0); + Result->SetStringField(TEXT("message"), FString::Printf( + TEXT("No function call nodes found targeting class '%s'"), *OldClass)); + } }; +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + UCLASS(meta=(ToolName="refresh_all_nodes_in_graph")) class UMCPHandler_RefreshAllNodesInGraph : public UObject, public IMCPHandler { @@ -349,9 +960,113 @@ public: "Reports compiler warnings and errors."); } - virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override; + virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override + { + + // Load Blueprint + FString LoadError; + UBlueprint* BP = UMCPAssetFinder::LoadBlueprintByName(Blueprint, LoadError); + if (!BP) + { + return MCPUtils::MakeErrorJson(Result, LoadError); + } + + // Count graphs and nodes before refresh + TArray AllGraphs; + BP->GetAllGraphs(AllGraphs); + int32 GraphCount = AllGraphs.Num(); + int32 NodeCount = 0; + for (UEdGraph* G : AllGraphs) + { + if (G) NodeCount += G->Nodes.Num(); + } + + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Refreshing all nodes in '%s' (%d graphs, %d nodes)"), + *Blueprint, GraphCount, NodeCount); + + // Refresh all nodes + FBlueprintEditorUtils::RefreshAllNodes(BP); + + // Remove orphaned pins from all nodes + int32 OrphanedPinsRemoved = 0; + for (UEdGraph* G : AllGraphs) + { + if (!G) continue; + for (UEdGraphNode* Node : G->Nodes) + { + if (!Node) continue; + for (int32 i = Node->Pins.Num() - 1; i >= 0; --i) + { + UEdGraphPin* Pin = Node->Pins[i]; + if (Pin && Pin->bOrphanedPin) + { + Pin->BreakAllPinLinks(); + Node->Pins.RemoveAt(i); + OrphanedPinsRemoved++; + } + } + } + } + + if (OrphanedPinsRemoved > 0) + { + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Removed %d orphaned pins"), OrphanedPinsRemoved); + } + + // Mark as modified and recompile after orphan removal + FBlueprintEditorUtils::MarkBlueprintAsStructurallyModified(BP); + + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: RefreshAllNodes complete")); + + // Collect compiler warnings and errors from the blueprint status + TArray> WarningsArr; + TArray> ErrorsArr; + + if (BP->Status == BS_Error) + { + ErrorsArr.Add(MakeShared(TEXT("Blueprint has compiler errors after refresh"))); + } + + // Check each graph for nodes with error/warning status + AllGraphs.Empty(); + BP->GetAllGraphs(AllGraphs); + for (UEdGraph* G : AllGraphs) + { + if (!G) continue; + for (UEdGraphNode* Node : G->Nodes) + { + if (!Node) continue; + if (Node->bHasCompilerMessage) + { + FString NodeTitle = Node->GetNodeTitle(ENodeTitleType::FullTitle).ToString(); + FString NodeMsg = FString::Printf(TEXT("[%s] %s: %s"), + *G->GetName(), *NodeTitle, *Node->ErrorMsg); + if (Node->ErrorType == EMessageSeverity::Error) + { + ErrorsArr.Add(MakeShared(NodeMsg)); + } + else + { + WarningsArr.Add(MakeShared(NodeMsg)); + } + } + } + } + + Result->SetBoolField(TEXT("success"), true); + Result->SetStringField(TEXT("blueprint"), Blueprint); + Result->SetNumberField(TEXT("graphCount"), GraphCount); + Result->SetNumberField(TEXT("nodeCount"), NodeCount); + Result->SetNumberField(TEXT("orphanedPinsRemoved"), OrphanedPinsRemoved); + Result->SetArrayField(TEXT("warnings"), WarningsArr); + Result->SetArrayField(TEXT("errors"), ErrorsArr); + } }; +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + UCLASS(meta=(ToolName="change_struct_node_type")) class UMCPHandler_ChangeStructNodeType : public UObject, public IMCPHandler { @@ -373,29 +1088,218 @@ public: "Attempts to reconnect matching pins after the type change."); } - virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override; -}; - -UCLASS(meta=(ToolName="rename_asset")) -class UMCPHandler_RenameAsset : public UObject, public IMCPHandler -{ - GENERATED_BODY() - -public: - UPROPERTY(meta=(Description="Current package path of the asset")) - FString AssetPath; - - UPROPERTY(meta=(Description="New package path or new asset name")) - FString NewPath; - - virtual FString GetDescription() const override + virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override { - return TEXT("Rename or move an asset with reference fixup."); - } - virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override; + // Load Blueprint + FString LoadError; + UBlueprint* BP = UMCPAssetFinder::LoadBlueprintByName(Blueprint, LoadError); + if (!BP) + { + return MCPUtils::MakeErrorJson(Result, LoadError); + } + + // Find node + UEdGraph* Graph = nullptr; + UEdGraphNode* Node = MCPUtils::FindNodeByGuid(BP, NodeId, &Graph); + if (!Node) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Node '%s' not found"), *NodeId)); + } + + // Determine what kind of struct node this is + UK2Node_BreakStruct* BreakNode = Cast(Node); + UK2Node_MakeStruct* MakeNode = Cast(Node); + + if (!BreakNode && !MakeNode) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Node '%s' is not a BreakStruct or MakeStruct node (class: %s)"), + *NodeId, *Node->GetClass()->GetName())); + } + + // Find the new struct type + FString SearchName = NewType; + if (SearchName.StartsWith(TEXT("F"))) + { + SearchName = SearchName.Mid(1); + } + + UScriptStruct* NewStruct = FindFirstObject(*SearchName); + if (!NewStruct) + { + // Try with full name including F prefix + NewStruct = FindFirstObject(*NewType); + } + if (!NewStruct) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Struct type '%s' not found"), *NewType)); + } + + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Changing struct node '%s' to type '%s'"), + *NodeId, *NewStruct->GetName()); + + // Helper: extract property base name from a BreakStruct pin name + auto ExtractPropertyBaseName = [](const FString& PinName) -> FString + { + // Find the last underscore before 32 hex chars (GUID) + int32 LastUnderscore; + if (PinName.FindLastChar(TEXT('_'), LastUnderscore) && (LastUnderscore > 0)) + { + FString Suffix = PinName.Mid(LastUnderscore + 1); + if (Suffix.Len() == 32) + { + FString WithoutGuid = PinName.Left(LastUnderscore); + // Strip _Index + int32 SecondUnderscore; + if (WithoutGuid.FindLastChar(TEXT('_'), SecondUnderscore) && (SecondUnderscore > 0)) + { + FString IndexStr = WithoutGuid.Mid(SecondUnderscore + 1); + if (IndexStr.IsNumeric()) + { + return WithoutGuid.Left(SecondUnderscore); + } + } + } + } + return PinName; + }; + + // Remember existing connections keyed by property base name + struct FPinConnection + { + EEdGraphPinDirection Direction; + TArray LinkedPins; + }; + TMap ConnectionsByBaseName; + + for (UEdGraphPin* Pin : Node->Pins) + { + if (!Pin || Pin->LinkedTo.Num() == 0) continue; + if (Pin->PinType.PinCategory == UEdGraphSchema_K2::PC_Exec) continue; + + FString BaseName = ExtractPropertyBaseName(Pin->PinName.ToString()); + FPinConnection& Conn = ConnectionsByBaseName.FindOrAdd(BaseName); + Conn.Direction = Pin->Direction; + Conn.LinkedPins = Pin->LinkedTo; + } + + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Saved %d pin connections to reconnect"), ConnectionsByBaseName.Num()); + + // Change the struct type and reconstruct + if (BreakNode) + { + BreakNode->StructType = NewStruct; + } + else if (MakeNode) + { + MakeNode->StructType = NewStruct; + } + + // Break all existing links before reconstruction + Node->BreakAllNodeLinks(); + + // Reconnect pins by matching property base names + const UEdGraphSchema* Schema = Graph->GetSchema(); + if (!Schema) + { + return MCPUtils::MakeErrorJson(Result, TEXT("Graph schema not found")); + } + + // Reconstruct to rebuild pins for the new struct type (use schema version for MinimalAPI compat) + Schema->ReconstructNode(*Node); + + int32 Reconnected = 0; + int32 Failed = 0; + TArray> ReconnectDetails; + + for (auto& Pair : ConnectionsByBaseName) + { + const FString& BaseName = Pair.Key; + const FPinConnection& OldConn = Pair.Value; + + // Find matching new pin + UEdGraphPin* NewPin = nullptr; + for (UEdGraphPin* Pin : Node->Pins) + { + if (!Pin || Pin->Direction != OldConn.Direction) continue; + FString NewBaseName = ExtractPropertyBaseName(Pin->PinName.ToString()); + if (NewBaseName.Equals(BaseName, ESearchCase::IgnoreCase)) + { + NewPin = Pin; + break; + } + } + + // Also try matching the struct input/output pin (single struct pin) + if (!NewPin) + { + for (UEdGraphPin* Pin : Node->Pins) + { + if (!Pin || Pin->Direction != OldConn.Direction) continue; + if ((Pin->PinType.PinCategory == UEdGraphSchema_K2::PC_Struct) && + (Pin->PinType.PinSubCategoryObject == NewStruct)) + { + NewPin = Pin; + break; + } + } + } + + if (NewPin) + { + for (UEdGraphPin* Target : OldConn.LinkedPins) + { + bool bOK = Schema->TryCreateConnection(NewPin, Target); + if (bOK) + { + Reconnected++; + } + else + { + Failed++; + } + + TSharedPtr Detail = MakeShared(); + Detail->SetStringField(TEXT("property"), BaseName); + Detail->SetBoolField(TEXT("connected"), bOK); + ReconnectDetails.Add(MakeShared(Detail)); + } + } + else + { + Failed += OldConn.LinkedPins.Num(); + TSharedPtr Detail = MakeShared(); + Detail->SetStringField(TEXT("property"), BaseName); + Detail->SetBoolField(TEXT("connected"), false); + Detail->SetStringField(TEXT("reason"), TEXT("No matching pin found on new struct")); + ReconnectDetails.Add(MakeShared(Detail)); + } + } + + FBlueprintEditorUtils::MarkBlueprintAsStructurallyModified(BP); + + // Return updated node state + TSharedPtr UpdatedNodeState = MCPUtils::SerializeNode(Node); + + Result->SetBoolField(TEXT("success"), true); + Result->SetStringField(TEXT("blueprint"), Blueprint); + Result->SetStringField(TEXT("nodeId"), NodeId); + Result->SetStringField(TEXT("newStructType"), NewStruct->GetName()); + Result->SetStringField(TEXT("nodeClass"), Node->GetClass()->GetName()); + Result->SetNumberField(TEXT("reconnected"), Reconnected); + Result->SetNumberField(TEXT("failed"), Failed); + Result->SetArrayField(TEXT("reconnectDetails"), ReconnectDetails); + if (UpdatedNodeState.IsValid()) + { + Result->SetObjectField(TEXT("updatedNode"), UpdatedNodeState); + } + } }; +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + UCLASS(meta=(ToolName="set_class_default_value")) class UMCPHandler_SetClassDefaultValue : public UObject, public IMCPHandler { @@ -417,9 +1321,164 @@ public: "Handles class references, object references, and simple types."); } - virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override; + virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override + { + + // Load Blueprint + FString LoadError; + UBlueprint* BP = UMCPAssetFinder::LoadBlueprintByName(Blueprint, LoadError); + if (!BP) + { + return MCPUtils::MakeErrorJson(Result, LoadError); + } + + if (!BP->GeneratedClass) + { + return MCPUtils::MakeErrorJson(Result, TEXT("Blueprint has no GeneratedClass")); + } + + UObject* CDO = BP->GeneratedClass->GetDefaultObject(); + if (!CDO) + { + return MCPUtils::MakeErrorJson(Result, TEXT("Could not get Class Default Object")); + } + + FProperty* Prop = BP->GeneratedClass->FindPropertyByName(*Property); + if (!Prop) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Property '%s' not found on '%s'"), *Property, *Blueprint)); + } + + FString OldValue; + Prop->ExportTextItem_Direct(OldValue, Prop->ContainerPtrToValuePtr(CDO), nullptr, CDO, PPF_None); + + bool bSuccess = false; + FString ActualNewValue; + + // Handle class/soft-class properties (TSubclassOf, TSoftClassPtr) + FClassProperty* ClassProp = CastField(Prop); + FSoftClassProperty* SoftClassProp = CastField(Prop); + + if (ClassProp || SoftClassProp) + { + // Resolve the value to a UClass* + UClass* ResolvedClass = nullptr; + + // Try as a C++ class name first + for (TObjectIterator It; It; ++It) + { + if (It->GetName() == Value || It->GetName() == Value + TEXT("_C")) + { + ResolvedClass = *It; + break; + } + } + + // Try loading as a Blueprint asset + if (!ResolvedClass) + { + FString BPLoadError; + UBlueprint* ValueBP = UMCPAssetFinder::LoadBlueprintByName(Value, BPLoadError); + if (ValueBP && ValueBP->GeneratedClass) + { + ResolvedClass = ValueBP->GeneratedClass; + } + } + + if (!ResolvedClass) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Could not resolve '%s' to a class"), *Value)); + } + + // Validate meta class compatibility + if (ClassProp) + { + UClass* MetaClass = ClassProp->MetaClass; + if (MetaClass && !ResolvedClass->IsChildOf(MetaClass)) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf( + TEXT("'%s' is not a subclass of '%s' (required by property '%s')"), + *ResolvedClass->GetName(), *MetaClass->GetName(), *Property)); + } + ClassProp->SetPropertyValue_InContainer(CDO, ResolvedClass); + } + else + { + FSoftObjectPtr SoftPtr(ResolvedClass); + SoftClassProp->SetPropertyValue_InContainer(CDO, SoftPtr); + } + ActualNewValue = ResolvedClass->GetName(); + bSuccess = true; + } + // Handle object properties (TObjectPtr, UObject*) + else if (FObjectProperty* ObjProp = CastField(Prop)) + { + // Try finding an existing object/asset by name + UObject* ResolvedObj = nullptr; + + // Try loading as a Blueprint asset + FString ObjLoadError; + UBlueprint* ValueBP = UMCPAssetFinder::LoadBlueprintByName(Value, ObjLoadError); + if (ValueBP && ValueBP->GeneratedClass) + { + ResolvedObj = ValueBP->GeneratedClass->GetDefaultObject(); + } + + if (!ResolvedObj) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Could not resolve '%s' to an object"), *Value)); + } + + ObjProp->SetPropertyValue_InContainer(CDO, ResolvedObj); + ActualNewValue = ResolvedObj->GetName(); + bSuccess = true; + } + // Handle simple types via ImportText + else + { + const TCHAR* ImportResult = Prop->ImportText_Direct(*Value, Prop->ContainerPtrToValuePtr(CDO), CDO, PPF_None); + if (ImportResult) + { + Prop->ExportTextItem_Direct(ActualNewValue, Prop->ContainerPtrToValuePtr(CDO), nullptr, CDO, PPF_None); + bSuccess = true; + } + else + { + return MCPUtils::MakeErrorJson(Result, FString::Printf( + TEXT("Failed to set property '%s' to '%s' — value could not be parsed for type '%s'"), + *Property, *Value, *Prop->GetCPPType())); + } + } + + if (!bSuccess) + { + return MCPUtils::MakeErrorJson(Result, TEXT("Failed to set property value")); + } + + // Mark modified and save + CDO->MarkPackageDirty(); + BP->Modify(); + + FKismetEditorUtilities::CompileBlueprint(BP); + bool bSaved = MCPUtils::SaveBlueprintPackage(BP); + + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Set '%s.%s' from '%s' to '%s' (saved: %s)"), + *Blueprint, *Property, *OldValue, *ActualNewValue, bSaved ? TEXT("true") : TEXT("false")); + + Result->SetBoolField(TEXT("success"), true); + Result->SetStringField(TEXT("blueprint"), Blueprint); + Result->SetStringField(TEXT("property"), Property); + Result->SetStringField(TEXT("oldValue"), OldValue); + Result->SetStringField(TEXT("newValue"), ActualNewValue); + Result->SetStringField(TEXT("propertyType"), Prop->GetCPPType()); + Result->SetBoolField(TEXT("saved"), bSaved); + } }; +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + UCLASS(meta=(ToolName="search_spawnable_node_types")) class UMCPHandler_SearchSpawnableNodeTypes : public UObject, public IMCPHandler { @@ -438,5 +1497,20 @@ public: "Returns full action names for use with spawn_node."); } - virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override; + virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override + { + int32 ClampedMax = FMath::Clamp(MaxResults, 1, 500); + + TArray Spawners = MCPUtils::SearchNodeSpawners(Query, ClampedMax); + + TArray> ResultArray; + for (UBlueprintNodeSpawner* Spawner : Spawners) + { + ResultArray.Add(MakeShared(MCPUtils::NodeSpawnerFullName(Spawner))); + } + + Result->SetBoolField(TEXT("success"), true); + Result->SetNumberField(TEXT("count"), ResultArray.Num()); + Result->SetArrayField(TEXT("results"), ResultArray); + } }; diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_PinMutation.h b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_PinMutation.h new file mode 100644 index 00000000..e0146f15 --- /dev/null +++ b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_PinMutation.h @@ -0,0 +1,427 @@ +#pragma once + +#include "CoreMinimal.h" +#include "MCPHandler.h" +#include "MCPAssetFinder.h" +#include "MCPUtils.h" +#include "Engine/Blueprint.h" +#include "EdGraph/EdGraph.h" +#include "EdGraph/EdGraphNode.h" +#include "EdGraph/EdGraphPin.h" +#include "Kismet2/BlueprintEditorUtils.h" +#include "MCPHandlers_PinMutation.generated.h" + +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + +USTRUCT() +struct FSetPinDefaultEntry +{ + GENERATED_BODY() + + UPROPERTY() + FString Blueprint; + + UPROPERTY() + FString NodeId; + + UPROPERTY() + FString PinName; + + UPROPERTY() + FString Value; +}; + + +UCLASS(meta=(ToolName="set_pin_default_values")) +class UMCPHandler_SetPinDefaultValues : public UObject, public IMCPHandler +{ + GENERATED_BODY() + +public: + UPROPERTY(meta=(Description="Array of {blueprint, nodeId, pinName, value} objects")) + FMCPJsonArray Pins; + + virtual FString GetDescription() const override + { + return TEXT("Set the default value of input pins on nodes."); + } + + virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override + { + + TArray> Results; + int32 SuccessCount = 0; + TSet ModifiedNodes; + TSet ModifiedBlueprints; + + for (const TSharedPtr& PinVal : Pins.Array) + { + TSharedRef EntryResult = MakeShared(); + Results.Add(MakeShared(EntryResult)); + + FSetPinDefaultEntry Entry; + FString PopulateError = MCPUtils::PopulateFromJson(FSetPinDefaultEntry::StaticStruct(), &Entry, PinVal); + if (!PopulateError.IsEmpty()) + { + EntryResult->SetStringField(TEXT("error"), PopulateError); + continue; + } + + EntryResult->SetStringField(TEXT("blueprint"), Entry.Blueprint); + EntryResult->SetStringField(TEXT("nodeId"), Entry.NodeId); + EntryResult->SetStringField(TEXT("pinName"), Entry.PinName); + + FString LoadError; + UBlueprint* BP = UMCPAssetFinder::LoadBlueprintByName(Entry.Blueprint, LoadError); + if (!BP) + { + EntryResult->SetStringField(TEXT("error"), LoadError); + continue; + } + + UEdGraph* Graph = nullptr; + UEdGraphNode* Node = MCPUtils::FindNodeByGuid(BP, Entry.NodeId, &Graph); + if (!Node) + { + EntryResult->SetStringField(TEXT("error"), FString::Printf(TEXT("Node '%s' not found"), *Entry.NodeId)); + continue; + } + + UEdGraphPin* Pin = Node->FindPin(FName(*Entry.PinName)); + if (!Pin) + { + EntryResult->SetStringField(TEXT("error"), FString::Printf(TEXT("Pin '%s' not found on node '%s'"), *Entry.PinName, *Entry.NodeId)); + continue; + } + + if (Pin->Direction != EGPD_Input) + { + EntryResult->SetStringField(TEXT("error"), FString::Printf(TEXT("Pin '%s' is an output pin"), *Entry.PinName)); + continue; + } + + const UEdGraphSchema* Schema = Graph->GetSchema(); + if (Schema) + { + FString ValidationError = Schema->IsPinDefaultValid(Pin, Entry.Value, nullptr, FText::GetEmpty()); + if (!ValidationError.IsEmpty()) + { + EntryResult->SetStringField(TEXT("error"), FString::Printf( + TEXT("Invalid value for pin '%s': %s"), *Entry.PinName, *ValidationError)); + continue; + } + } + + FString OldValue = Pin->DefaultValue; + Pin->DefaultValue = Entry.Value; + + EntryResult->SetBoolField(TEXT("success"), true); + EntryResult->SetStringField(TEXT("oldValue"), OldValue); + EntryResult->SetStringField(TEXT("newValue"), Pin->DefaultValue); + SuccessCount++; + ModifiedNodes.Add(Node); + ModifiedBlueprints.Add(BP); + } + + for (UEdGraphNode* Node : ModifiedNodes) + { + Node->ReconstructNode(); + } + + for (UBlueprint* BP : ModifiedBlueprints) + { + FBlueprintEditorUtils::MarkBlueprintAsModified(BP); + } + + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: SetPinDefault — %d/%d succeeded"), + SuccessCount, Pins.Array.Num()); + + Result->SetBoolField(TEXT("success"), true); + Result->SetNumberField(TEXT("successCount"), SuccessCount); + Result->SetNumberField(TEXT("totalCount"), Pins.Array.Num()); + Result->SetArrayField(TEXT("results"), Results); + } +}; + +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + +USTRUCT() +struct FConnectPinsEntry +{ + GENERATED_BODY() + + UPROPERTY() + FString SourceNodeId; + + UPROPERTY() + FString SourcePinName; + + UPROPERTY() + FString TargetNodeId; + + UPROPERTY() + FString TargetPinName; +}; + + +UCLASS(meta=(ToolName="connect_blueprint_pins")) +class UMCPHandler_ConnectBlueprintPins : public UObject, public IMCPHandler +{ + GENERATED_BODY() + +public: + UPROPERTY(meta=(Description="Blueprint name or package path")) + FString Blueprint; + + UPROPERTY(meta=(Description="Array of {sourceNodeId, sourcePinName, targetNodeId, targetPinName} objects")) + FMCPJsonArray Connections; + + virtual FString GetDescription() const override + { + return TEXT("Connect pins between nodes in a Blueprint graph."); + } + + virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override + { + + FString LoadError; + UBlueprint* BP = UMCPAssetFinder::LoadBlueprintByName(Blueprint, LoadError); + if (!BP) + { + return MCPUtils::MakeErrorJson(Result, LoadError); + } + + TArray> Results; + int32 SuccessCount = 0; + + for (const TSharedPtr& ConnVal : Connections.Array) + { + TSharedRef EntryResult = MakeShared(); + Results.Add(MakeShared(EntryResult)); + + FConnectPinsEntry Entry; + FString PopulateError = MCPUtils::PopulateFromJson(FConnectPinsEntry::StaticStruct(), &Entry, ConnVal); + if (!PopulateError.IsEmpty()) + { + EntryResult->SetStringField(TEXT("error"), PopulateError); + continue; + } + + EntryResult->SetStringField(TEXT("sourceNodeId"), Entry.SourceNodeId); + EntryResult->SetStringField(TEXT("sourcePinName"), Entry.SourcePinName); + EntryResult->SetStringField(TEXT("targetNodeId"), Entry.TargetNodeId); + EntryResult->SetStringField(TEXT("targetPinName"), Entry.TargetPinName); + + UEdGraph* SourceGraph = nullptr; + UEdGraphNode* SourceNode = MCPUtils::FindNodeByGuid(BP, Entry.SourceNodeId, &SourceGraph); + if (!SourceNode) + { + EntryResult->SetStringField(TEXT("error"), FString::Printf(TEXT("Source node '%s' not found"), *Entry.SourceNodeId)); + continue; + } + + UEdGraphNode* TargetNode = MCPUtils::FindNodeByGuid(BP, Entry.TargetNodeId); + if (!TargetNode) + { + EntryResult->SetStringField(TEXT("error"), FString::Printf(TEXT("Target node '%s' not found"), *Entry.TargetNodeId)); + continue; + } + + UEdGraphPin* SourcePin = SourceNode->FindPin(FName(*Entry.SourcePinName)); + if (!SourcePin) + { + EntryResult->SetStringField(TEXT("error"), FString::Printf(TEXT("Source pin '%s' not found on node '%s'"), *Entry.SourcePinName, *Entry.SourceNodeId)); + continue; + } + + UEdGraphPin* TargetPin = TargetNode->FindPin(FName(*Entry.TargetPinName)); + if (!TargetPin) + { + EntryResult->SetStringField(TEXT("error"), FString::Printf(TEXT("Target pin '%s' not found on node '%s'"), *Entry.TargetPinName, *Entry.TargetNodeId)); + continue; + } + + const UEdGraphSchema* Schema = SourceGraph->GetSchema(); + if (!Schema) + { + EntryResult->SetStringField(TEXT("error"), TEXT("Graph schema not found")); + continue; + } + + bool bConnected = Schema->TryCreateConnection(SourcePin, TargetPin); + if (!bConnected) + { + EntryResult->SetStringField(TEXT("error"), FString::Printf( + TEXT("Cannot connect %s (%s) to %s (%s) — types are incompatible"), + *Entry.SourcePinName, *SourcePin->PinType.PinCategory.ToString(), + *Entry.TargetPinName, *TargetPin->PinType.PinCategory.ToString())); + continue; + } + + EntryResult->SetBoolField(TEXT("success"), true); + SuccessCount++; + } + + if (SuccessCount > 0) + { + FBlueprintEditorUtils::MarkBlueprintAsModified(BP); + } + + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: ConnectPins — %d/%d succeeded in '%s'"), + SuccessCount, Connections.Array.Num(), *Blueprint); + + Result->SetBoolField(TEXT("success"), true); + Result->SetStringField(TEXT("blueprint"), Blueprint); + Result->SetNumberField(TEXT("successCount"), SuccessCount); + Result->SetNumberField(TEXT("totalCount"), Connections.Array.Num()); + Result->SetArrayField(TEXT("results"), Results); + } +}; + +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + +USTRUCT() +struct FDisconnectPinEntry +{ + GENERATED_BODY() + + UPROPERTY() + FString NodeId; + + UPROPERTY() + FString PinName; + + UPROPERTY(meta=(Optional)) + FString TargetNodeId; + + UPROPERTY(meta=(Optional)) + FString TargetPinName; +}; + + +UCLASS(meta=(ToolName="disconnect_blueprint_pins")) +class UMCPHandler_DisconnectBlueprintPins : public UObject, public IMCPHandler +{ + GENERATED_BODY() + +public: + UPROPERTY(meta=(Description="Blueprint name or package path")) + FString Blueprint; + + UPROPERTY(meta=(Description="Array of {nodeId, pinName, targetNodeId?, targetPinName?} objects. If target is omitted, all connections on the pin are broken.")) + FMCPJsonArray Disconnections; + + virtual FString GetDescription() const override + { + return TEXT("Disconnect pins in a Blueprint graph. " + "Can disconnect a specific link or all links on a pin."); + } + + virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override + { + + FString LoadError; + UBlueprint* BP = UMCPAssetFinder::LoadBlueprintByName(Blueprint, LoadError); + if (!BP) + { + return MCPUtils::MakeErrorJson(Result, LoadError); + } + + TArray> Results; + int32 SuccessCount = 0; + int32 TotalDisconnected = 0; + + for (const TSharedPtr& DiscVal : Disconnections.Array) + { + TSharedRef EntryResult = MakeShared(); + Results.Add(MakeShared(EntryResult)); + + FDisconnectPinEntry Entry; + FString PopulateError = MCPUtils::PopulateFromJson(FDisconnectPinEntry::StaticStruct(), &Entry, DiscVal); + if (!PopulateError.IsEmpty()) + { + EntryResult->SetStringField(TEXT("error"), PopulateError); + continue; + } + + EntryResult->SetStringField(TEXT("nodeId"), Entry.NodeId); + EntryResult->SetStringField(TEXT("pinName"), Entry.PinName); + + UEdGraphNode* Node = MCPUtils::FindNodeByGuid(BP, Entry.NodeId); + if (!Node) + { + EntryResult->SetStringField(TEXT("error"), FString::Printf(TEXT("Node '%s' not found"), *Entry.NodeId)); + continue; + } + + UEdGraphPin* Pin = Node->FindPin(FName(*Entry.PinName)); + if (!Pin) + { + EntryResult->SetStringField(TEXT("error"), FString::Printf(TEXT("Pin '%s' not found on node '%s'"), *Entry.PinName, *Entry.NodeId)); + continue; + } + + int32 DisconnectedCount = 0; + + if (!Entry.TargetNodeId.IsEmpty() && !Entry.TargetPinName.IsEmpty()) + { + UEdGraphNode* TargetNode = MCPUtils::FindNodeByGuid(BP, Entry.TargetNodeId); + if (!TargetNode) + { + EntryResult->SetStringField(TEXT("error"), FString::Printf(TEXT("Target node '%s' not found"), *Entry.TargetNodeId)); + continue; + } + + UEdGraphPin* TargetPin = TargetNode->FindPin(FName(*Entry.TargetPinName)); + if (!TargetPin) + { + EntryResult->SetStringField(TEXT("error"), FString::Printf(TEXT("Target pin '%s' not found on node '%s'"), *Entry.TargetPinName, *Entry.TargetNodeId)); + continue; + } + + if (!Pin->LinkedTo.Contains(TargetPin)) + { + EntryResult->SetStringField(TEXT("error"), TEXT("The specified pins are not connected to each other")); + continue; + } + + Pin->BreakLinkTo(TargetPin); + DisconnectedCount = 1; + } + else + { + DisconnectedCount = Pin->LinkedTo.Num(); + if (DisconnectedCount > 0) + { + Pin->BreakAllPinLinks(true); + } + } + + EntryResult->SetBoolField(TEXT("success"), true); + EntryResult->SetNumberField(TEXT("disconnectedCount"), DisconnectedCount); + SuccessCount++; + TotalDisconnected += DisconnectedCount; + } + + if (TotalDisconnected > 0) + { + FBlueprintEditorUtils::MarkBlueprintAsModified(BP); + } + + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: DisconnectPin — %d/%d succeeded, %d links broken in '%s'"), + SuccessCount, Disconnections.Array.Num(), TotalDisconnected, *Blueprint); + + Result->SetBoolField(TEXT("success"), true); + Result->SetStringField(TEXT("blueprint"), Blueprint); + Result->SetNumberField(TEXT("successCount"), SuccessCount); + Result->SetNumberField(TEXT("totalCount"), Disconnections.Array.Num()); + Result->SetNumberField(TEXT("totalDisconnected"), TotalDisconnected); + Result->SetArrayField(TEXT("results"), Results); + } +}; diff --git a/tools/inline-methods.py b/tools/inline-methods.py new file mode 100644 index 00000000..e84bf338 --- /dev/null +++ b/tools/inline-methods.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +""" +Inlines method bodies from a .cpp file into the corresponding .h file. + +Scans the .cpp for method definitions matching "ClassName::MethodName(...)" +and extracts the body (from opening '{' to closing '}'). Then reads the .h +file and replaces matching declaration-only lines (ending with ';') with the +declaration (minus ';') followed by the body. + +Usage: python3 tools/inline-methods.py + +Outputs the new header to stdout. +""" + +import sys +import re + + +def extract_bodies(cpp_lines): + """Extract method bodies from cpp file. + Returns dict of (ClassName, MethodName) -> list of body lines (from '{' to '}').""" + bodies = {} + i = 0 + while i < len(cpp_lines): + # Match lines like: void UMCPHandler_Foo::Handle(...) + m = re.match(r'^\S.*?\s+(\w+)::(\w+)\s*\(', cpp_lines[i]) + if not m: + i += 1 + continue + + class_name = m.group(1) + method_name = m.group(2) + + # Skip forward to the line containing '{' + while i < len(cpp_lines) and '{' not in cpp_lines[i]: + i += 1 + if i >= len(cpp_lines): + break + + # Collect from '{' to matching '}' + brace_depth = 0 + body_lines = [] + while i < len(cpp_lines): + line = cpp_lines[i] + brace_depth += line.count('{') - line.count('}') + body_lines.append(line) + i += 1 + if brace_depth == 0: + break + + bodies[(class_name, method_name)] = body_lines + + return bodies + + +def inline_into_header(h_lines, bodies): + """Replace declaration-only methods in header with inlined bodies.""" + output = [] + for line in h_lines: + # Match declaration lines like: + # \tvirtual void Handle(const FJsonObject* Json, ...) override; + # Capture: indent, everything before method name, method name, rest up to ';' + m = re.match(r'^(\t+)(.*?\s+)(\w+)\s*(\(.*\)\s*(?:const\s*)?(?:override\s*)?);', line) + if m: + indent = m.group(1) + prefix = m.group(2) # e.g. "virtual void " + method_name = m.group(3) + params = m.group(4) # e.g. "(const FJsonObject* Json, FJsonObject* Result) override" + + # Find which class we're inside + class_name = None + for prev in reversed(output): + cm = re.match(r'^class\s+(\w+)\s*', prev) + if cm: + class_name = cm.group(1) + break + + if class_name and (class_name, method_name) in bodies: + body_lines = bodies[(class_name, method_name)] + # Emit the declaration as a definition (replace ';' with body) + output.append(f'{indent}{prefix}{method_name}{params}') + for bline in body_lines: + if bline.strip(): + output.append(indent + bline) + else: + output.append('') + continue + + output.append(line) + return output + + +def main(): + if len(sys.argv) != 3: + print(f"Usage: {sys.argv[0]} ", file=sys.stderr) + sys.exit(1) + + h_path = sys.argv[1] + cpp_path = sys.argv[2] + + with open(cpp_path) as f: + cpp_lines = [line.rstrip('\n') for line in f] + + with open(h_path) as f: + h_lines = [line.rstrip('\n') for line in f] + + bodies = extract_bodies(cpp_lines) + + if not bodies: + print("No method bodies found in cpp file.", file=sys.stderr) + sys.exit(1) + + print(f"Found {len(bodies)} method(s) to inline:", file=sys.stderr) + for (cls, method) in bodies: + print(f" {cls}::{method}", file=sys.stderr) + + result = inline_into_header(h_lines, bodies) + + for line in result: + print(line) + + +if __name__ == '__main__': + main()