Initial checkin of Blueprint-MCP plugin

This commit is contained in:
2026-03-05 19:26:46 -05:00
parent 9cc1cb502b
commit 8367bd2221
4571 changed files with 1211887 additions and 7 deletions

View File

@@ -0,0 +1,36 @@
using UnrealBuildTool;
public class BlueprintMCP : ModuleRules
{
public BlueprintMCP(ReadOnlyTargetRules Target) : base(Target)
{
PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;
PublicDependencyModuleNames.AddRange(new string[]
{
"Core",
"CoreUObject",
"Engine",
"UnrealEd",
"BlueprintGraph",
"Json",
"JsonUtilities",
"HTTPServer",
"Sockets",
"Networking"
});
PrivateDependencyModuleNames.AddRange(new string[]
{
"AssetRegistry",
"AssetTools",
"Kismet",
"KismetCompiler",
"EditorSubsystem",
"MaterialEditor",
"AnimGraph",
"AnimGraphRuntime",
"RHI"
});
}
}

View File

@@ -0,0 +1,61 @@
#include "BlueprintMCPCommandlet.h"
#include "BlueprintMCPServer.h"
#include "Containers/Ticker.h"
UBlueprintMCPCommandlet::UBlueprintMCPCommandlet()
{
IsClient = false;
IsEditor = true;
IsServer = false;
LogToConsole = true;
}
int32 UBlueprintMCPCommandlet::Main(const FString& Params)
{
// Parse port from command-line params
TArray<FString> Tokens;
TArray<FString> Switches;
TMap<FString, FString> ParamMap;
ParseCommandLine(*Params, Tokens, Switches, ParamMap);
int32 Port = 9847;
if (ParamMap.Contains(TEXT("port")))
{
Port = FCString::Atoi(*ParamMap[TEXT("port")]);
}
// Create and start the shared server
Server = MakeUnique<FBlueprintMCPServer>();
if (!Server->Start(Port))
{
return 1;
}
// Main loop — tick the engine systems and process queued requests one at a time
double LastTime = FPlatformTime::Seconds();
auto TickEngine = [&LastTime]()
{
double CurrentTime = FPlatformTime::Seconds();
double DeltaTime = CurrentTime - LastTime;
LastTime = CurrentTime;
FTSTicker::GetCoreTicker().Tick(DeltaTime);
};
while (!IsEngineExitRequested())
{
TickEngine();
if (Server->ProcessOneRequest())
{
// Tick again immediately after completing a request so pending
// HTTP responses get flushed.
TickEngine();
}
FPlatformProcess::Sleep(0.01f);
}
Server->Stop();
return 0;
}

View File

@@ -0,0 +1,54 @@
#include "BlueprintMCPEditorSubsystem.h"
#include "BlueprintMCPServer.h"
void UBlueprintMCPEditorSubsystem::Initialize(FSubsystemCollectionBase& Collection)
{
Super::Initialize(Collection);
// Don't start in commandlet mode — the commandlet has its own server instance.
if (IsRunningCommandlet())
{
return;
}
Server = MakeUnique<FBlueprintMCPServer>();
if (Server->Start(9847, /*bEditorMode=*/true))
{
UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Editor subsystem started — MCP server on port %d"), Server->GetPort());
}
else
{
UE_LOG(LogTemp, Warning, TEXT("BlueprintMCP: Editor subsystem failed to start MCP server (port may be in use)"));
Server.Reset();
}
}
void UBlueprintMCPEditorSubsystem::Deinitialize()
{
if (Server)
{
Server->Stop();
Server.Reset();
UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Editor subsystem stopped."));
}
Super::Deinitialize();
}
void UBlueprintMCPEditorSubsystem::Tick(float DeltaTime)
{
if (Server)
{
Server->ProcessOneRequest();
}
}
bool UBlueprintMCPEditorSubsystem::IsTickable() const
{
return Server.IsValid() && Server->IsRunning();
}
TStatId UBlueprintMCPEditorSubsystem::GetStatId() const
{
RETURN_QUICK_DECLARE_CYCLE_STAT(UBlueprintMCPEditorSubsystem, STATGROUP_Tickables);
}

View File

@@ -0,0 +1,368 @@
#include "BlueprintMCPServer.h"
#include "Engine/Blueprint.h"
#include "Engine/SimpleConstructionScript.h"
#include "Engine/SCS_Node.h"
#include "Components/ActorComponent.h"
#include "Kismet2/BlueprintEditorUtils.h"
#include "Serialization/JsonReader.h"
#include "Serialization/JsonWriter.h"
#include "Serialization/JsonSerializer.h"
#include "UObject/UObjectIterator.h"
// ============================================================
// HandleListComponents — list all components in a Blueprint's SCS
// ============================================================
FString FBlueprintMCPServer::HandleListComponents(const FString& Body)
{
TSharedPtr<FJsonObject> Json = ParseBodyJson(Body);
if (!Json.IsValid())
{
return MakeErrorJson(TEXT("Invalid JSON body"));
}
FString BlueprintName = Json->GetStringField(TEXT("blueprint"));
if (BlueprintName.IsEmpty())
{
return MakeErrorJson(TEXT("Missing required field: blueprint"));
}
FString LoadError;
UBlueprint* BP = LoadBlueprintByName(BlueprintName, LoadError);
if (!BP)
{
return MakeErrorJson(LoadError);
}
USimpleConstructionScript* SCS = BP->SimpleConstructionScript;
if (!SCS)
{
return MakeErrorJson(FString::Printf(
TEXT("Blueprint '%s' does not have a SimpleConstructionScript (not an Actor Blueprint)"),
*BlueprintName));
}
const TArray<USCS_Node*>& AllNodes = SCS->GetAllNodes();
TArray<TSharedPtr<FJsonValue>> ComponentsArr;
for (USCS_Node* Node : AllNodes)
{
if (!Node)
{
continue;
}
TSharedRef<FJsonObject> CompObj = MakeShared<FJsonObject>();
CompObj->SetStringField(TEXT("name"), Node->GetVariableName().ToString());
if (Node->ComponentClass)
{
CompObj->SetStringField(TEXT("componentClass"), Node->ComponentClass->GetName());
}
else
{
CompObj->SetStringField(TEXT("componentClass"), TEXT("None"));
}
// Parent component info
USCS_Node* ParentNode = nullptr;
for (USCS_Node* Candidate : AllNodes)
{
if (Candidate && Candidate->GetChildNodes().Contains(Node))
{
ParentNode = Candidate;
break;
}
}
if (ParentNode)
{
CompObj->SetStringField(TEXT("parentComponent"), ParentNode->GetVariableName().ToString());
}
// Check if this is a default scene root (first root node with SceneComponent class)
bool bIsSceneRoot = false;
const TArray<USCS_Node*>& RootNodes = SCS->GetRootNodes();
if (RootNodes.Num() > 0 && RootNodes[0] == Node)
{
bIsSceneRoot = true;
}
CompObj->SetBoolField(TEXT("isSceneRoot"), bIsSceneRoot);
// List child count for informational purposes
CompObj->SetNumberField(TEXT("childCount"), Node->GetChildNodes().Num());
ComponentsArr.Add(MakeShared<FJsonValueObject>(CompObj));
}
TSharedRef<FJsonObject> Result = MakeShared<FJsonObject>();
Result->SetStringField(TEXT("blueprint"), BlueprintName);
Result->SetNumberField(TEXT("count"), ComponentsArr.Num());
Result->SetArrayField(TEXT("components"), ComponentsArr);
return JsonToString(Result);
}
// ============================================================
// HandleAddComponent — add a component to a Blueprint's SCS
// ============================================================
FString FBlueprintMCPServer::HandleAddComponent(const FString& Body)
{
TSharedPtr<FJsonObject> Json = ParseBodyJson(Body);
if (!Json.IsValid())
{
return MakeErrorJson(TEXT("Invalid JSON body"));
}
FString BlueprintName = Json->GetStringField(TEXT("blueprint"));
FString ComponentClassName = Json->GetStringField(TEXT("componentClass"));
FString ComponentName = Json->GetStringField(TEXT("name"));
if (BlueprintName.IsEmpty() || ComponentClassName.IsEmpty() || ComponentName.IsEmpty())
{
return MakeErrorJson(TEXT("Missing required fields: blueprint, componentClass, name"));
}
FString ParentComponentName;
if (Json->HasField(TEXT("parentComponent")))
{
ParentComponentName = Json->GetStringField(TEXT("parentComponent"));
}
FString LoadError;
UBlueprint* BP = LoadBlueprintByName(BlueprintName, LoadError);
if (!BP)
{
return MakeErrorJson(LoadError);
}
USimpleConstructionScript* SCS = BP->SimpleConstructionScript;
if (!SCS)
{
return MakeErrorJson(FString::Printf(
TEXT("Blueprint '%s' does not have a SimpleConstructionScript (not an Actor Blueprint)"),
*BlueprintName));
}
// Check for duplicate component names
const TArray<USCS_Node*>& ExistingNodes = SCS->GetAllNodes();
for (USCS_Node* Existing : ExistingNodes)
{
if (Existing && Existing->GetVariableName().ToString().Equals(ComponentName, ESearchCase::IgnoreCase))
{
return MakeErrorJson(FString::Printf(
TEXT("A component named '%s' already exists in Blueprint '%s'"),
*ComponentName, *BlueprintName));
}
}
// Resolve the component class by name
// Try multiple name variants: exact name, with U prefix, without U prefix
UClass* ComponentClass = nullptr;
TArray<FString> NamesToTry;
NamesToTry.Add(ComponentClassName);
if (!ComponentClassName.StartsWith(TEXT("U")))
{
NamesToTry.Add(FString::Printf(TEXT("U%s"), *ComponentClassName));
}
else
{
// Also try without U prefix
NamesToTry.Add(ComponentClassName.Mid(1));
}
for (TObjectIterator<UClass> It; It; ++It)
{
if (!It->IsChildOf(UActorComponent::StaticClass()))
{
continue;
}
FString ClassName = It->GetName();
for (const FString& NameToTry : NamesToTry)
{
if (ClassName.Equals(NameToTry, ESearchCase::IgnoreCase))
{
ComponentClass = *It;
break;
}
}
if (ComponentClass)
{
break;
}
}
if (!ComponentClass)
{
return MakeErrorJson(FString::Printf(
TEXT("Component class '%s' not found or is not a subclass of UActorComponent. "
"Common classes: StaticMeshComponent, SkeletalMeshComponent, AudioComponent, "
"SceneComponent, BoxCollisionComponent, SphereCollisionComponent, CapsuleComponent, "
"ArrowComponent, ChildActorComponent, SpotLightComponent, PointLightComponent, "
"WidgetComponent, BillboardComponent"),
*ComponentClassName));
}
// If parent component specified, find its SCS node
USCS_Node* ParentSCSNode = nullptr;
if (!ParentComponentName.IsEmpty())
{
for (USCS_Node* Node : ExistingNodes)
{
if (Node && Node->GetVariableName().ToString().Equals(ParentComponentName, ESearchCase::IgnoreCase))
{
ParentSCSNode = Node;
break;
}
}
if (!ParentSCSNode)
{
return MakeErrorJson(FString::Printf(
TEXT("Parent component '%s' not found in Blueprint '%s'"),
*ParentComponentName, *BlueprintName));
}
}
UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Adding component '%s' (%s) to Blueprint '%s'"),
*ComponentName, *ComponentClass->GetName(), *BlueprintName);
// Create the SCS node
USCS_Node* NewNode = SCS->CreateNode(ComponentClass, FName(*ComponentName));
if (!NewNode)
{
return MakeErrorJson(FString::Printf(
TEXT("Failed to create SCS node for component '%s' with class '%s'"),
*ComponentName, *ComponentClass->GetName()));
}
// Add to the hierarchy
if (ParentSCSNode)
{
ParentSCSNode->AddChildNode(NewNode);
}
else
{
SCS->AddNode(NewNode);
}
FBlueprintEditorUtils::MarkBlueprintAsStructurallyModified(BP);
bool bSaved = SaveBlueprintPackage(BP);
UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Added component '%s' (%s) to '%s' (parent: %s, saved: %s)"),
*ComponentName, *ComponentClass->GetName(), *BlueprintName,
ParentSCSNode ? *ParentComponentName : TEXT("(root)"),
bSaved ? TEXT("true") : TEXT("false"));
TSharedRef<FJsonObject> Result = MakeShared<FJsonObject>();
Result->SetBoolField(TEXT("success"), true);
Result->SetStringField(TEXT("blueprint"), BlueprintName);
Result->SetStringField(TEXT("name"), NewNode->GetVariableName().ToString());
Result->SetStringField(TEXT("componentClass"), ComponentClass->GetName());
if (ParentSCSNode)
{
Result->SetStringField(TEXT("parentComponent"), ParentSCSNode->GetVariableName().ToString());
}
Result->SetBoolField(TEXT("saved"), bSaved);
return JsonToString(Result);
}
// ============================================================
// HandleRemoveComponent — remove a component from a Blueprint's SCS
// ============================================================
FString FBlueprintMCPServer::HandleRemoveComponent(const FString& Body)
{
TSharedPtr<FJsonObject> Json = ParseBodyJson(Body);
if (!Json.IsValid())
{
return MakeErrorJson(TEXT("Invalid JSON body"));
}
FString BlueprintName = Json->GetStringField(TEXT("blueprint"));
FString ComponentName = Json->GetStringField(TEXT("name"));
if (BlueprintName.IsEmpty() || ComponentName.IsEmpty())
{
return MakeErrorJson(TEXT("Missing required fields: blueprint, name"));
}
FString LoadError;
UBlueprint* BP = LoadBlueprintByName(BlueprintName, LoadError);
if (!BP)
{
return MakeErrorJson(LoadError);
}
USimpleConstructionScript* SCS = BP->SimpleConstructionScript;
if (!SCS)
{
return MakeErrorJson(FString::Printf(
TEXT("Blueprint '%s' does not have a SimpleConstructionScript (not an Actor Blueprint)"),
*BlueprintName));
}
// Find the node to remove
USCS_Node* NodeToRemove = nullptr;
const TArray<USCS_Node*>& AllNodes = SCS->GetAllNodes();
for (USCS_Node* Node : AllNodes)
{
if (Node && Node->GetVariableName().ToString().Equals(ComponentName, ESearchCase::IgnoreCase))
{
NodeToRemove = Node;
break;
}
}
if (!NodeToRemove)
{
// Build list of component names for the error message
TArray<TSharedPtr<FJsonValue>> CompList;
for (USCS_Node* Node : AllNodes)
{
if (Node)
{
CompList.Add(MakeShared<FJsonValueString>(Node->GetVariableName().ToString()));
}
}
TSharedRef<FJsonObject> ErrorResult = MakeShared<FJsonObject>();
ErrorResult->SetStringField(TEXT("error"), FString::Printf(
TEXT("Component '%s' not found in Blueprint '%s'"),
*ComponentName, *BlueprintName));
ErrorResult->SetArrayField(TEXT("existingComponents"), CompList);
return JsonToString(ErrorResult);
}
// Prevent removing the root scene component if it has children
const TArray<USCS_Node*>& RootNodes = SCS->GetRootNodes();
if (RootNodes.Contains(NodeToRemove) && NodeToRemove->GetChildNodes().Num() > 0)
{
return MakeErrorJson(FString::Printf(
TEXT("Cannot remove component '%s' because it is a root component with %d child(ren). "
"Remove or re-parent the children first."),
*ComponentName, NodeToRemove->GetChildNodes().Num()));
}
UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Removing component '%s' from Blueprint '%s'"),
*ComponentName, *BlueprintName);
// Remove the node (promotes children to parent if it has any — but we've guarded root above)
SCS->RemoveNodeAndPromoteChildren(NodeToRemove);
FBlueprintEditorUtils::MarkBlueprintAsStructurallyModified(BP);
bool bSaved = SaveBlueprintPackage(BP);
UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Removed component '%s' from '%s' (saved: %s)"),
*ComponentName, *BlueprintName, bSaved ? TEXT("true") : TEXT("false"));
TSharedRef<FJsonObject> Result = MakeShared<FJsonObject>();
Result->SetBoolField(TEXT("success"), true);
Result->SetStringField(TEXT("blueprint"), BlueprintName);
Result->SetStringField(TEXT("name"), ComponentName);
Result->SetBoolField(TEXT("saved"), bSaved);
return JsonToString(Result);
}

View File

@@ -0,0 +1,269 @@
#include "BlueprintMCPServer.h"
#include "Engine/Blueprint.h"
#include "EdGraph/EdGraph.h"
#include "EdGraph/EdGraphNode.h"
#include "EdGraph/EdGraphPin.h"
#include "K2Node_BreakStruct.h"
#include "K2Node_MakeStruct.h"
#include "K2Node_CallFunction.h"
#include "K2Node_VariableGet.h"
#include "K2Node_VariableSet.h"
#include "Kismet2/BlueprintEditorUtils.h"
#include "Serialization/JsonReader.h"
#include "Serialization/JsonWriter.h"
#include "Serialization/JsonSerializer.h"
// ============================================================
// HandleDiffBlueprints — structural diff between two Blueprints
// ============================================================
FString FBlueprintMCPServer::HandleDiffBlueprints(const FString& Body)
{
TSharedPtr<FJsonObject> Json = ParseBodyJson(Body);
if (!Json.IsValid())
{
return MakeErrorJson(TEXT("Invalid JSON body"));
}
FString BlueprintA = Json->GetStringField(TEXT("blueprintA"));
FString BlueprintB = Json->GetStringField(TEXT("blueprintB"));
FString GraphFilter = Json->GetStringField(TEXT("graph"));
if (BlueprintA.IsEmpty() || BlueprintB.IsEmpty())
{
return MakeErrorJson(TEXT("Missing required fields: blueprintA, blueprintB"));
}
// Load both blueprints
FString LoadErrorA, LoadErrorB;
UBlueprint* BPA = LoadBlueprintByName(BlueprintA, LoadErrorA);
if (!BPA) return MakeErrorJson(FString::Printf(TEXT("blueprintA: %s"), *LoadErrorA));
UBlueprint* BPB = LoadBlueprintByName(BlueprintB, LoadErrorB);
if (!BPB) return MakeErrorJson(FString::Printf(TEXT("blueprintB: %s"), *LoadErrorB));
// Helper to gather graphs from a Blueprint
auto GatherGraphs = [&GraphFilter](UBlueprint* BP) -> TArray<UEdGraph*>
{
TArray<UEdGraph*> Graphs;
for (UEdGraph* G : BP->UbergraphPages)
{
if (!G) continue;
if (!GraphFilter.IsEmpty() && G->GetName() != GraphFilter) continue;
Graphs.Add(G);
}
for (UEdGraph* G : BP->FunctionGraphs)
{
if (!G) continue;
if (!GraphFilter.IsEmpty() && G->GetName() != GraphFilter) continue;
Graphs.Add(G);
}
return Graphs;
};
TArray<UEdGraph*> GraphsA = GatherGraphs(BPA);
TArray<UEdGraph*> GraphsB = GatherGraphs(BPB);
// Build graph name maps
TMap<FString, UEdGraph*> GraphMapA, GraphMapB;
for (UEdGraph* G : GraphsA) GraphMapA.Add(G->GetName(), G);
for (UEdGraph* G : GraphsB) GraphMapB.Add(G->GetName(), G);
// Compare graphs
TArray<TSharedPtr<FJsonValue>> GraphDiffs;
// Find all unique graph names
TSet<FString> 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<FJsonObject> GD = MakeShared<FJsonObject>();
GD->SetStringField(TEXT("graph"), GraphName);
if (!pGA)
{
GD->SetStringField(TEXT("status"), TEXT("onlyInB"));
GD->SetNumberField(TEXT("nodeCountB"), (*pGB)->Nodes.Num());
GraphDiffs.Add(MakeShared<FJsonValueObject>(GD));
continue;
}
if (!pGB)
{
GD->SetStringField(TEXT("status"), TEXT("onlyInA"));
GD->SetNumberField(TEXT("nodeCountA"), (*pGA)->Nodes.Num());
GraphDiffs.Add(MakeShared<FJsonValueObject>(GD));
continue;
}
// Both exist — compare nodes
UEdGraph* GA = *pGA;
UEdGraph* GB = *pGB;
// Build node title maps for matching (title -> node list for each)
TMap<FString, TArray<UEdGraphNode*>> 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<TSharedPtr<FJsonValue>> OnlyInA;
for (auto& Pair : NodesA)
{
int32 CountA = Pair.Value.Num();
int32 CountB = 0;
if (TArray<UEdGraphNode*>* pArr = NodesB.Find(Pair.Key))
{
CountB = pArr->Num();
}
if (CountA > CountB)
{
TSharedRef<FJsonObject> NObj = MakeShared<FJsonObject>();
NObj->SetStringField(TEXT("title"), Pair.Key);
NObj->SetStringField(TEXT("class"), Pair.Value[0]->GetClass()->GetName());
NObj->SetNumberField(TEXT("extraCount"), CountA - CountB);
OnlyInA.Add(MakeShared<FJsonValueObject>(NObj));
}
}
// Nodes only in B
TArray<TSharedPtr<FJsonValue>> OnlyInB;
for (auto& Pair : NodesB)
{
int32 CountB = Pair.Value.Num();
int32 CountA = 0;
if (TArray<UEdGraphNode*>* pArr = NodesA.Find(Pair.Key))
{
CountA = pArr->Num();
}
if (CountB > CountA)
{
TSharedRef<FJsonObject> NObj = MakeShared<FJsonObject>();
NObj->SetStringField(TEXT("title"), Pair.Key);
NObj->SetStringField(TEXT("class"), Pair.Value[0]->GetClass()->GetName());
NObj->SetNumberField(TEXT("extraCount"), CountB - CountA);
OnlyInB.Add(MakeShared<FJsonValueObject>(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<FString> 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<TSharedPtr<FJsonValue>> ConnsOnlyInA, ConnsOnlyInB;
for (const FString& Key : ConnectionsA)
{
if (!ConnectionsB.Contains(Key))
{
ConnsOnlyInA.Add(MakeShared<FJsonValueString>(Key));
}
}
for (const FString& Key : ConnectionsB)
{
if (!ConnectionsA.Contains(Key))
{
ConnsOnlyInB.Add(MakeShared<FJsonValueString>(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<FJsonValueObject>(GD));
}
// Compare variables
TArray<TSharedPtr<FJsonValue>> VarsOnlyInA, VarsOnlyInB;
TSet<FString> 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<FJsonValueString>(Name));
}
}
for (const FString& Name : VarNamesB)
{
if (!VarNamesA.Contains(Name))
{
VarsOnlyInB.Add(MakeShared<FJsonValueString>(Name));
}
}
TSharedRef<FJsonObject> Result = MakeShared<FJsonObject>();
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);
return JsonToString(Result);
}

View File

@@ -0,0 +1,549 @@
#include "BlueprintMCPServer.h"
#include "Engine/Blueprint.h"
#include "EdGraph/EdGraph.h"
#include "EdGraph/EdGraphNode.h"
#include "EdGraph/EdGraphPin.h"
#include "EdGraphSchema_K2.h"
#include "K2Node.h"
#include "Kismet2/BlueprintEditorUtils.h"
#include "Serialization/JsonReader.h"
#include "Serialization/JsonWriter.h"
#include "Serialization/JsonSerializer.h"
#include "UObject/UObjectIterator.h"
// ============================================================
// HandleGetPinInfo — detailed information about a specific pin
// ============================================================
FString FBlueprintMCPServer::HandleGetPinInfo(const FString& Body)
{
TSharedPtr<FJsonObject> Json = ParseBodyJson(Body);
if (!Json.IsValid())
{
return MakeErrorJson(TEXT("Invalid JSON body"));
}
FString BlueprintName = Json->GetStringField(TEXT("blueprint"));
FString NodeId = Json->GetStringField(TEXT("nodeId"));
FString PinName = Json->GetStringField(TEXT("pinName"));
if (BlueprintName.IsEmpty() || NodeId.IsEmpty() || PinName.IsEmpty())
{
return MakeErrorJson(TEXT("Missing required fields: blueprint, nodeId, pinName"));
}
FString LoadError;
UBlueprint* BP = LoadBlueprintByName(BlueprintName, LoadError);
if (!BP)
{
return MakeErrorJson(LoadError);
}
UEdGraph* Graph = nullptr;
UEdGraphNode* Node = FindNodeByGuid(BP, NodeId, &Graph);
if (!Node)
{
return MakeErrorJson(FString::Printf(TEXT("Node '%s' not found"), *NodeId));
}
UEdGraphPin* Pin = Node->FindPin(FName(*PinName));
if (!Pin)
{
// List available pins
TArray<TSharedPtr<FJsonValue>> AvailPins;
for (UEdGraphPin* P : Node->Pins)
{
if (P)
{
TSharedRef<FJsonObject> PinObj = MakeShared<FJsonObject>();
PinObj->SetStringField(TEXT("name"), P->PinName.ToString());
PinObj->SetStringField(TEXT("direction"), P->Direction == EGPD_Input ? TEXT("Input") : TEXT("Output"));
PinObj->SetStringField(TEXT("type"), P->PinType.PinCategory.ToString());
AvailPins.Add(MakeShared<FJsonValueObject>(PinObj));
}
}
TSharedRef<FJsonObject> E = MakeShared<FJsonObject>();
E->SetStringField(TEXT("error"), FString::Printf(TEXT("Pin '%s' not found on node '%s'"), *PinName, *NodeId));
E->SetArrayField(TEXT("availablePins"), AvailPins);
return JsonToString(E);
}
TSharedRef<FJsonObject> Result = MakeShared<FJsonObject>();
Result->SetBoolField(TEXT("success"), true);
Result->SetStringField(TEXT("blueprint"), BlueprintName);
Result->SetStringField(TEXT("nodeId"), NodeId);
Result->SetStringField(TEXT("pinName"), Pin->PinName.ToString());
Result->SetStringField(TEXT("direction"), Pin->Direction == EGPD_Input ? TEXT("Input") : TEXT("Output"));
Result->SetStringField(TEXT("type"), Pin->PinType.PinCategory.ToString());
if (!Pin->PinType.PinSubCategory.IsNone())
{
Result->SetStringField(TEXT("subCategory"), Pin->PinType.PinSubCategory.ToString());
}
if (Pin->PinType.PinSubCategoryObject.IsValid())
{
Result->SetStringField(TEXT("subtype"), Pin->PinType.PinSubCategoryObject->GetName());
}
Result->SetBoolField(TEXT("isArray"), Pin->PinType.IsArray());
Result->SetBoolField(TEXT("isSet"), Pin->PinType.IsSet());
Result->SetBoolField(TEXT("isMap"), Pin->PinType.IsMap());
Result->SetBoolField(TEXT("isReference"), Pin->PinType.bIsReference);
Result->SetBoolField(TEXT("isConst"), Pin->PinType.bIsConst);
if (!Pin->DefaultValue.IsEmpty())
{
Result->SetStringField(TEXT("defaultValue"), Pin->DefaultValue);
}
if (!Pin->DefaultTextValue.IsEmpty())
{
Result->SetStringField(TEXT("defaultTextValue"), Pin->DefaultTextValue.ToString());
}
if (Pin->DefaultObject)
{
Result->SetStringField(TEXT("defaultObject"), Pin->DefaultObject->GetPathName());
}
// Connected pins
if (Pin->LinkedTo.Num() > 0)
{
TArray<TSharedPtr<FJsonValue>> Conns;
for (UEdGraphPin* Linked : Pin->LinkedTo)
{
if (!Linked || !Linked->GetOwningNode()) continue;
TSharedRef<FJsonObject> CJ = MakeShared<FJsonObject>();
CJ->SetStringField(TEXT("nodeId"), Linked->GetOwningNode()->NodeGuid.ToString());
CJ->SetStringField(TEXT("pinName"), Linked->PinName.ToString());
CJ->SetStringField(TEXT("nodeTitle"), Linked->GetOwningNode()->GetNodeTitle(ENodeTitleType::FullTitle).ToString());
Conns.Add(MakeShared<FJsonValueObject>(CJ));
}
Result->SetArrayField(TEXT("connectedTo"), Conns);
}
return JsonToString(Result);
}
// ============================================================
// HandleCheckPinCompatibility — pre-flight check for connect_pins
// ============================================================
FString FBlueprintMCPServer::HandleCheckPinCompatibility(const FString& Body)
{
TSharedPtr<FJsonObject> Json = ParseBodyJson(Body);
if (!Json.IsValid())
{
return MakeErrorJson(TEXT("Invalid JSON body"));
}
FString BlueprintName = Json->GetStringField(TEXT("blueprint"));
FString SourceNodeId = Json->GetStringField(TEXT("sourceNodeId"));
FString SourcePinName = Json->GetStringField(TEXT("sourcePinName"));
FString TargetNodeId = Json->GetStringField(TEXT("targetNodeId"));
FString TargetPinName = Json->GetStringField(TEXT("targetPinName"));
if (BlueprintName.IsEmpty() || SourceNodeId.IsEmpty() || SourcePinName.IsEmpty() ||
TargetNodeId.IsEmpty() || TargetPinName.IsEmpty())
{
return MakeErrorJson(TEXT("Missing required fields: blueprint, sourceNodeId, sourcePinName, targetNodeId, targetPinName"));
}
FString LoadError;
UBlueprint* BP = LoadBlueprintByName(BlueprintName, LoadError);
if (!BP)
{
return MakeErrorJson(LoadError);
}
UEdGraph* SourceGraph = nullptr;
UEdGraphNode* SourceNode = FindNodeByGuid(BP, SourceNodeId, &SourceGraph);
if (!SourceNode)
{
return MakeErrorJson(FString::Printf(TEXT("Source node '%s' not found"), *SourceNodeId));
}
UEdGraphNode* TargetNode = FindNodeByGuid(BP, TargetNodeId);
if (!TargetNode)
{
return MakeErrorJson(FString::Printf(TEXT("Target node '%s' not found"), *TargetNodeId));
}
UEdGraphPin* SourcePin = SourceNode->FindPin(FName(*SourcePinName));
if (!SourcePin)
{
return MakeErrorJson(FString::Printf(TEXT("Source pin '%s' not found on node '%s'"), *SourcePinName, *SourceNodeId));
}
UEdGraphPin* TargetPin = TargetNode->FindPin(FName(*TargetPinName));
if (!TargetPin)
{
return MakeErrorJson(FString::Printf(TEXT("Target pin '%s' not found on node '%s'"), *TargetPinName, *TargetNodeId));
}
const UEdGraphSchema* Schema = SourceGraph ? SourceGraph->GetSchema() : nullptr;
if (!Schema)
{
return MakeErrorJson(TEXT("Graph schema not found"));
}
// Check compatibility using the schema
const FPinConnectionResponse Response = Schema->CanCreateConnection(SourcePin, TargetPin);
TSharedRef<FJsonObject> Result = MakeShared<FJsonObject>();
Result->SetBoolField(TEXT("success"), true);
Result->SetStringField(TEXT("blueprint"), BlueprintName);
bool bCompatible = (Response.Response != ECanCreateConnectionResponse::CONNECT_RESPONSE_DISALLOW);
Result->SetBoolField(TEXT("compatible"), bCompatible);
// Decode the response type
FString ResponseType;
switch (Response.Response)
{
case ECanCreateConnectionResponse::CONNECT_RESPONSE_MAKE:
ResponseType = TEXT("direct");
break;
case ECanCreateConnectionResponse::CONNECT_RESPONSE_BREAK_OTHERS_A:
ResponseType = TEXT("breakSourceConnections");
break;
case ECanCreateConnectionResponse::CONNECT_RESPONSE_BREAK_OTHERS_B:
ResponseType = TEXT("breakTargetConnections");
break;
case ECanCreateConnectionResponse::CONNECT_RESPONSE_BREAK_OTHERS_AB:
ResponseType = TEXT("breakBothConnections");
break;
case ECanCreateConnectionResponse::CONNECT_RESPONSE_MAKE_WITH_CONVERSION_NODE:
ResponseType = TEXT("requiresConversion");
break;
case ECanCreateConnectionResponse::CONNECT_RESPONSE_MAKE_WITH_PROMOTION:
ResponseType = TEXT("requiresPromotion");
break;
case ECanCreateConnectionResponse::CONNECT_RESPONSE_DISALLOW:
default:
ResponseType = TEXT("disallowed");
break;
}
Result->SetStringField(TEXT("connectionType"), ResponseType);
if (!Response.Message.IsEmpty())
{
Result->SetStringField(TEXT("message"), Response.Message.ToString());
}
// Include pin type info for context
Result->SetStringField(TEXT("sourcePinType"), SourcePin->PinType.PinCategory.ToString());
if (SourcePin->PinType.PinSubCategoryObject.IsValid())
Result->SetStringField(TEXT("sourcePinSubtype"), SourcePin->PinType.PinSubCategoryObject->GetName());
Result->SetStringField(TEXT("targetPinType"), TargetPin->PinType.PinCategory.ToString());
if (TargetPin->PinType.PinSubCategoryObject.IsValid())
Result->SetStringField(TEXT("targetPinSubtype"), TargetPin->PinType.PinSubCategoryObject->GetName());
return JsonToString(Result);
}
// ============================================================
// HandleListClasses — discover available UClasses
// ============================================================
FString FBlueprintMCPServer::HandleListClasses(const FString& Body)
{
TSharedPtr<FJsonObject> Json = ParseBodyJson(Body);
if (!Json.IsValid())
{
return MakeErrorJson(TEXT("Invalid JSON body"));
}
FString Filter = Json->GetStringField(TEXT("filter"));
FString ParentClassName = Json->GetStringField(TEXT("parentClass"));
int32 Limit = 100;
if (Json->HasField(TEXT("limit")))
{
Limit = FMath::Clamp(Json->GetIntegerField(TEXT("limit")), 1, 500);
}
UClass* ParentClass = nullptr;
if (!ParentClassName.IsEmpty())
{
for (TObjectIterator<UClass> It; It; ++It)
{
if (It->GetName() == ParentClassName || It->GetName() == ParentClassName + TEXT("_C"))
{
ParentClass = *It;
break;
}
}
if (!ParentClass)
{
return MakeErrorJson(FString::Printf(TEXT("Parent class '%s' not found"), *ParentClassName));
}
}
TArray<TSharedPtr<FJsonValue>> ClassList;
int32 TotalMatched = 0;
for (TObjectIterator<UClass> It; It; ++It)
{
UClass* Class = *It;
if (!Class) continue;
// Skip internal/deprecated classes
if (Class->HasAnyClassFlags(CLASS_Deprecated | CLASS_NewerVersionExists)) continue;
// Apply parent filter
if (ParentClass && !Class->IsChildOf(ParentClass)) continue;
FString ClassName = Class->GetName();
// Apply name filter
if (!Filter.IsEmpty() && !ClassName.Contains(Filter, ESearchCase::IgnoreCase))
{
continue;
}
TotalMatched++;
if (ClassList.Num() >= Limit) continue; // Count but don't add beyond limit
TSharedRef<FJsonObject> ClassObj = MakeShared<FJsonObject>();
ClassObj->SetStringField(TEXT("name"), ClassName);
ClassObj->SetStringField(TEXT("fullPath"), Class->GetPathName());
// Determine if it's a Blueprint-generated class
bool bIsBlueprint = Class->ClassGeneratedBy != nullptr;
ClassObj->SetBoolField(TEXT("isBlueprint"), bIsBlueprint);
// Parent class
if (Class->GetSuperClass())
{
ClassObj->SetStringField(TEXT("parentClass"), Class->GetSuperClass()->GetName());
}
// Module/package info
UPackage* Package = Class->GetOuterUPackage();
if (Package)
{
ClassObj->SetStringField(TEXT("package"), Package->GetName());
}
// Flags
TArray<TSharedPtr<FJsonValue>> Flags;
if (Class->HasAnyClassFlags(CLASS_Abstract)) Flags.Add(MakeShared<FJsonValueString>(TEXT("Abstract")));
if (Class->HasAnyClassFlags(CLASS_Interface)) Flags.Add(MakeShared<FJsonValueString>(TEXT("Interface")));
if (Class->HasAnyClassFlags(CLASS_MinimalAPI)) Flags.Add(MakeShared<FJsonValueString>(TEXT("MinimalAPI")));
if (Flags.Num() > 0)
{
ClassObj->SetArrayField(TEXT("flags"), Flags);
}
ClassList.Add(MakeShared<FJsonValueObject>(ClassObj));
}
TSharedRef<FJsonObject> Result = MakeShared<FJsonObject>();
Result->SetBoolField(TEXT("success"), true);
Result->SetNumberField(TEXT("count"), ClassList.Num());
Result->SetNumberField(TEXT("totalMatched"), TotalMatched);
if (TotalMatched > Limit)
{
Result->SetBoolField(TEXT("truncated"), true);
Result->SetNumberField(TEXT("limit"), Limit);
}
Result->SetArrayField(TEXT("classes"), ClassList);
return JsonToString(Result);
}
// ============================================================
// HandleListFunctions — list Blueprint-callable functions on a class
// ============================================================
FString FBlueprintMCPServer::HandleListFunctions(const FString& Body)
{
TSharedPtr<FJsonObject> Json = ParseBodyJson(Body);
if (!Json.IsValid())
{
return MakeErrorJson(TEXT("Invalid JSON body"));
}
FString ClassName = Json->GetStringField(TEXT("className"));
FString Filter = Json->GetStringField(TEXT("filter"));
if (ClassName.IsEmpty())
{
return MakeErrorJson(TEXT("Missing required field: className"));
}
// Find the class
UClass* FoundClass = nullptr;
for (TObjectIterator<UClass> It; It; ++It)
{
if (It->GetName() == ClassName || It->GetName() == ClassName + TEXT("_C"))
{
FoundClass = *It;
break;
}
}
if (!FoundClass)
{
return MakeErrorJson(FString::Printf(TEXT("Class '%s' not found"), *ClassName));
}
TArray<TSharedPtr<FJsonValue>> FuncList;
for (TFieldIterator<UFunction> FuncIt(FoundClass); FuncIt; ++FuncIt)
{
UFunction* Func = *FuncIt;
if (!Func) continue;
// Only include Blueprint-callable functions
if (!Func->HasAnyFunctionFlags(FUNC_BlueprintCallable | FUNC_BlueprintPure | FUNC_BlueprintEvent)) continue;
FString FuncName = Func->GetName();
// Apply filter
if (!Filter.IsEmpty() && !FuncName.Contains(Filter, ESearchCase::IgnoreCase))
{
continue;
}
TSharedRef<FJsonObject> FuncObj = MakeShared<FJsonObject>();
FuncObj->SetStringField(TEXT("name"), FuncName);
// Determine the owning class
UClass* OwnerClass = Func->GetOwnerClass();
if (OwnerClass)
{
FuncObj->SetStringField(TEXT("definedIn"), OwnerClass->GetName());
}
// Function flags
FuncObj->SetBoolField(TEXT("isPure"), Func->HasAnyFunctionFlags(FUNC_BlueprintPure));
FuncObj->SetBoolField(TEXT("isStatic"), Func->HasAnyFunctionFlags(FUNC_Static));
FuncObj->SetBoolField(TEXT("isEvent"), Func->HasAnyFunctionFlags(FUNC_BlueprintEvent));
FuncObj->SetBoolField(TEXT("isConst"), Func->HasAnyFunctionFlags(FUNC_Const));
// Parameters
TArray<TSharedPtr<FJsonValue>> Params;
FString ReturnType;
for (TFieldIterator<FProperty> PropIt(Func); PropIt; ++PropIt)
{
FProperty* Prop = *PropIt;
if (!Prop) continue;
FString PropType = Prop->GetCPPType();
if (Prop->HasAnyPropertyFlags(CPF_ReturnParm))
{
ReturnType = PropType;
continue;
}
if (Prop->HasAnyPropertyFlags(CPF_Parm))
{
TSharedRef<FJsonObject> ParamObj = MakeShared<FJsonObject>();
ParamObj->SetStringField(TEXT("name"), Prop->GetName());
ParamObj->SetStringField(TEXT("type"), PropType);
ParamObj->SetBoolField(TEXT("isOutput"), Prop->HasAnyPropertyFlags(CPF_OutParm) && !Prop->HasAnyPropertyFlags(CPF_ReferenceParm));
ParamObj->SetBoolField(TEXT("isReference"), Prop->HasAnyPropertyFlags(CPF_ReferenceParm));
Params.Add(MakeShared<FJsonValueObject>(ParamObj));
}
}
FuncObj->SetArrayField(TEXT("parameters"), Params);
if (!ReturnType.IsEmpty())
{
FuncObj->SetStringField(TEXT("returnType"), ReturnType);
}
FuncList.Add(MakeShared<FJsonValueObject>(FuncObj));
}
TSharedRef<FJsonObject> Result = MakeShared<FJsonObject>();
Result->SetBoolField(TEXT("success"), true);
Result->SetStringField(TEXT("className"), FoundClass->GetName());
Result->SetNumberField(TEXT("count"), FuncList.Num());
Result->SetArrayField(TEXT("functions"), FuncList);
return JsonToString(Result);
}
// ============================================================
// HandleListProperties — list properties on a class
// ============================================================
FString FBlueprintMCPServer::HandleListProperties(const FString& Body)
{
TSharedPtr<FJsonObject> Json = ParseBodyJson(Body);
if (!Json.IsValid())
{
return MakeErrorJson(TEXT("Invalid JSON body"));
}
FString ClassName = Json->GetStringField(TEXT("className"));
FString Filter = Json->GetStringField(TEXT("filter"));
if (ClassName.IsEmpty())
{
return MakeErrorJson(TEXT("Missing required field: className"));
}
// Find the class
UClass* FoundClass = nullptr;
for (TObjectIterator<UClass> It; It; ++It)
{
if (It->GetName() == ClassName || It->GetName() == ClassName + TEXT("_C"))
{
FoundClass = *It;
break;
}
}
if (!FoundClass)
{
return MakeErrorJson(FString::Printf(TEXT("Class '%s' not found"), *ClassName));
}
TArray<TSharedPtr<FJsonValue>> PropList;
for (TFieldIterator<FProperty> PropIt(FoundClass); PropIt; ++PropIt)
{
FProperty* Prop = *PropIt;
if (!Prop) continue;
FString PropName = Prop->GetName();
// Apply filter
if (!Filter.IsEmpty() && !PropName.Contains(Filter, ESearchCase::IgnoreCase))
{
continue;
}
TSharedRef<FJsonObject> PropObj = MakeShared<FJsonObject>();
PropObj->SetStringField(TEXT("name"), PropName);
PropObj->SetStringField(TEXT("type"), Prop->GetCPPType());
// Determine the owning class
UClass* OwnerClass = Prop->GetOwnerClass();
if (OwnerClass)
{
PropObj->SetStringField(TEXT("definedIn"), OwnerClass->GetName());
}
// Property flags
TArray<TSharedPtr<FJsonValue>> Flags;
if (Prop->HasAnyPropertyFlags(CPF_BlueprintVisible)) Flags.Add(MakeShared<FJsonValueString>(TEXT("BlueprintVisible")));
if (Prop->HasAnyPropertyFlags(CPF_BlueprintReadOnly)) Flags.Add(MakeShared<FJsonValueString>(TEXT("BlueprintReadOnly")));
if (Prop->HasAnyPropertyFlags(CPF_Edit)) Flags.Add(MakeShared<FJsonValueString>(TEXT("EditAnywhere")));
if (Prop->HasAnyPropertyFlags(CPF_EditConst)) Flags.Add(MakeShared<FJsonValueString>(TEXT("VisibleOnly")));
if (Prop->HasAnyPropertyFlags(CPF_Config)) Flags.Add(MakeShared<FJsonValueString>(TEXT("Config")));
if (Prop->HasAnyPropertyFlags(CPF_SaveGame)) Flags.Add(MakeShared<FJsonValueString>(TEXT("SaveGame")));
if (Prop->HasAnyPropertyFlags(CPF_Transient)) Flags.Add(MakeShared<FJsonValueString>(TEXT("Transient")));
if (Prop->HasAnyPropertyFlags(CPF_RepNotify)) Flags.Add(MakeShared<FJsonValueString>(TEXT("RepNotify")));
if (Flags.Num() > 0)
{
PropObj->SetArrayField(TEXT("flags"), Flags);
}
PropList.Add(MakeShared<FJsonValueObject>(PropObj));
}
TSharedRef<FJsonObject> Result = MakeShared<FJsonObject>();
Result->SetBoolField(TEXT("success"), true);
Result->SetStringField(TEXT("className"), FoundClass->GetName());
Result->SetNumberField(TEXT("count"), PropList.Num());
Result->SetArrayField(TEXT("properties"), PropList);
return JsonToString(Result);
}

View File

@@ -0,0 +1,245 @@
#include "BlueprintMCPServer.h"
#include "Engine/Blueprint.h"
#include "EdGraph/EdGraph.h"
#include "EdGraph/EdGraphPin.h"
#include "K2Node_FunctionEntry.h"
#include "K2Node_EditablePinBase.h"
#include "Kismet2/BlueprintEditorUtils.h"
#include "Kismet2/KismetEditorUtilities.h"
#include "Serialization/JsonReader.h"
#include "Serialization/JsonWriter.h"
#include "Serialization/JsonSerializer.h"
// ============================================================
// HandleAddEventDispatcher — create a multicast delegate on a Blueprint
// ============================================================
FString FBlueprintMCPServer::HandleAddEventDispatcher(const FString& Body)
{
TSharedPtr<FJsonObject> Json = ParseBodyJson(Body);
if (!Json.IsValid())
{
return MakeErrorJson(TEXT("Invalid JSON body"));
}
FString BlueprintName = Json->GetStringField(TEXT("blueprint"));
FString DispatcherName = Json->GetStringField(TEXT("dispatcherName"));
if (BlueprintName.IsEmpty() || DispatcherName.IsEmpty())
{
return MakeErrorJson(TEXT("Missing required fields: blueprint, dispatcherName"));
}
// Load Blueprint
FString LoadError;
UBlueprint* BP = LoadBlueprintByName(BlueprintName, LoadError);
if (!BP)
{
return MakeErrorJson(LoadError);
}
FName DispatcherFName(*DispatcherName);
// Check for name uniqueness against existing variables
for (const FBPVariableDescription& Var : BP->NewVariables)
{
if (Var.VarName == DispatcherFName)
{
return MakeErrorJson(FString::Printf(
TEXT("A variable or dispatcher named '%s' already exists in Blueprint '%s'"),
*DispatcherName, *BlueprintName));
}
}
// Check against existing graphs (functions, macros, etc.)
TArray<UEdGraph*> AllGraphs;
BP->GetAllGraphs(AllGraphs);
for (UEdGraph* Existing : AllGraphs)
{
if (Existing && Existing->GetName().Equals(DispatcherName, ESearchCase::IgnoreCase))
{
return MakeErrorJson(FString::Printf(
TEXT("A graph named '%s' already exists in Blueprint '%s'"),
*DispatcherName, *BlueprintName));
}
}
UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Adding event dispatcher '%s' to Blueprint '%s'"),
*DispatcherName, *BlueprintName);
// Step 1: Add a member variable with PC_MCDelegate pin type
FEdGraphPinType DelegateType;
DelegateType.PinCategory = UEdGraphSchema_K2::PC_MCDelegate;
bool bVarAdded = FBlueprintEditorUtils::AddMemberVariable(BP, DispatcherFName, DelegateType);
if (!bVarAdded)
{
return MakeErrorJson(FString::Printf(
TEXT("Failed to add delegate variable for '%s'"), *DispatcherName));
}
// Step 2: Create the signature graph
const UEdGraphSchema_K2* K2Schema = GetDefault<UEdGraphSchema_K2>();
UEdGraph* SigGraph = FBlueprintEditorUtils::CreateNewGraph(BP, DispatcherFName,
UEdGraph::StaticClass(), UEdGraphSchema_K2::StaticClass());
if (!SigGraph)
{
return MakeErrorJson(TEXT("Failed to create delegate signature graph"));
}
K2Schema->CreateDefaultNodesForGraph(*SigGraph);
K2Schema->CreateFunctionGraphTerminators(*SigGraph, static_cast<UClass*>(nullptr));
K2Schema->AddExtraFunctionFlags(SigGraph, FUNC_BlueprintCallable | FUNC_BlueprintEvent | FUNC_Public);
K2Schema->MarkFunctionEntryAsEditable(SigGraph, true);
BP->DelegateSignatureGraphs.Add(SigGraph);
// Step 3: Add parameters if provided
TArray<TSharedPtr<FJsonValue>> ParamsArr;
if (Json->HasField(TEXT("parameters")))
{
ParamsArr = Json->GetArrayField(TEXT("parameters"));
}
TArray<TSharedPtr<FJsonValue>> AddedParamsJson;
if (ParamsArr.Num() > 0)
{
// Find the entry node in the signature graph
UK2Node_EditablePinBase* EntryNode = nullptr;
for (UEdGraphNode* Node : SigGraph->Nodes)
{
if (UK2Node_FunctionEntry* FE = Cast<UK2Node_FunctionEntry>(Node))
{
EntryNode = FE;
break;
}
}
if (!EntryNode)
{
// Still save what we have
FBlueprintEditorUtils::MarkBlueprintAsStructurallyModified(BP);
SaveBlueprintPackage(BP);
return MakeErrorJson(TEXT("Event dispatcher created but entry node not found — parameters could not be added"));
}
for (const TSharedPtr<FJsonValue>& ParamVal : ParamsArr)
{
if (!ParamVal.IsValid() || ParamVal->Type != EJson::Object) continue;
TSharedPtr<FJsonObject> ParamObj = ParamVal->AsObject();
FString ParamName = ParamObj->GetStringField(TEXT("name"));
FString ParamType = ParamObj->GetStringField(TEXT("type"));
if (ParamName.IsEmpty() || ParamType.IsEmpty()) continue;
FEdGraphPinType PinType;
FString TypeError;
if (!ResolveTypeFromString(ParamType, PinType, TypeError))
{
return MakeErrorJson(FString::Printf(
TEXT("Parameter '%s': %s"), *ParamName, *TypeError));
}
EntryNode->CreateUserDefinedPin(FName(*ParamName), PinType, EGPD_Output);
TSharedRef<FJsonObject> ParamJson = MakeShared<FJsonObject>();
ParamJson->SetStringField(TEXT("name"), ParamName);
ParamJson->SetStringField(TEXT("type"), ParamType);
AddedParamsJson.Add(MakeShared<FJsonValueObject>(ParamJson));
}
}
FBlueprintEditorUtils::MarkBlueprintAsStructurallyModified(BP);
bool bSaved = SaveBlueprintPackage(BP);
UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Added event dispatcher '%s' to '%s' with %d params (saved: %s)"),
*DispatcherName, *BlueprintName, AddedParamsJson.Num(), bSaved ? TEXT("true") : TEXT("false"));
TSharedRef<FJsonObject> Result = MakeShared<FJsonObject>();
Result->SetBoolField(TEXT("success"), true);
Result->SetStringField(TEXT("blueprint"), BlueprintName);
Result->SetStringField(TEXT("dispatcherName"), DispatcherName);
Result->SetArrayField(TEXT("parameters"), AddedParamsJson);
Result->SetBoolField(TEXT("saved"), bSaved);
return JsonToString(Result);
}
// ============================================================
// HandleListEventDispatchers — list all event dispatchers on a Blueprint
// ============================================================
FString FBlueprintMCPServer::HandleListEventDispatchers(const FString& Body)
{
TSharedPtr<FJsonObject> Json = ParseBodyJson(Body);
if (!Json.IsValid())
{
return MakeErrorJson(TEXT("Invalid JSON body"));
}
FString BlueprintName = Json->GetStringField(TEXT("blueprint"));
if (BlueprintName.IsEmpty())
{
return MakeErrorJson(TEXT("Missing required field: blueprint"));
}
FString LoadError;
UBlueprint* BP = LoadBlueprintByName(BlueprintName, LoadError);
if (!BP)
{
return MakeErrorJson(LoadError);
}
TSet<FName> DelegateNameSet;
FBlueprintEditorUtils::GetDelegateNameList(BP, DelegateNameSet);
TArray<TSharedPtr<FJsonValue>> DispatchersArr;
for (const FName& DelegateName : DelegateNameSet)
{
TSharedRef<FJsonObject> DispObj = MakeShared<FJsonObject>();
DispObj->SetStringField(TEXT("name"), DelegateName.ToString());
// Get parameter info from the signature graph
TArray<TSharedPtr<FJsonValue>> ParamsArr;
UEdGraph* SigGraph = FBlueprintEditorUtils::GetDelegateSignatureGraphByName(BP, DelegateName);
if (SigGraph)
{
for (UEdGraphNode* Node : SigGraph->Nodes)
{
UK2Node_FunctionEntry* FE = Cast<UK2Node_FunctionEntry>(Node);
if (!FE) continue;
for (const TSharedPtr<FUserPinInfo>& PinInfo : FE->UserDefinedPins)
{
if (!PinInfo.IsValid()) continue;
TSharedRef<FJsonObject> ParamObj = MakeShared<FJsonObject>();
ParamObj->SetStringField(TEXT("name"), PinInfo->PinName.ToString());
// Build a human-readable type name from the pin type
FString TypeStr = PinInfo->PinType.PinCategory.ToString();
if (PinInfo->PinType.PinSubCategoryObject.IsValid())
{
TypeStr = PinInfo->PinType.PinSubCategoryObject->GetName();
}
ParamObj->SetStringField(TEXT("type"), TypeStr);
ParamsArr.Add(MakeShared<FJsonValueObject>(ParamObj));
}
break; // only need the first entry node
}
}
DispObj->SetArrayField(TEXT("parameters"), ParamsArr);
DispatchersArr.Add(MakeShared<FJsonValueObject>(DispObj));
}
TSharedRef<FJsonObject> Result = MakeShared<FJsonObject>();
Result->SetStringField(TEXT("blueprint"), BlueprintName);
Result->SetNumberField(TEXT("count"), DispatchersArr.Num());
Result->SetArrayField(TEXT("dispatchers"), DispatchersArr);
return JsonToString(Result);
}

View File

@@ -0,0 +1,594 @@
#include "BlueprintMCPServer.h"
#include "Engine/Blueprint.h"
#include "Engine/World.h"
#include "EdGraph/EdGraph.h"
#include "EdGraph/EdGraphNode.h"
#include "EdGraphSchema_K2.h"
#include "K2Node_CustomEvent.h"
#include "K2Node_FunctionEntry.h"
#include "Kismet2/BlueprintEditorUtils.h"
#include "Kismet2/KismetEditorUtilities.h"
#include "Serialization/JsonReader.h"
#include "Serialization/JsonWriter.h"
#include "Serialization/JsonSerializer.h"
#include "UObject/UObjectIterator.h"
#include "AssetRegistry/AssetRegistryModule.h"
#include "AssetRegistry/IAssetRegistry.h"
// ============================================================
// HandleReparentBlueprint — change a Blueprint's parent class
// ============================================================
FString FBlueprintMCPServer::HandleReparentBlueprint(const FString& Body)
{
TSharedPtr<FJsonObject> Json = ParseBodyJson(Body);
if (!Json.IsValid())
{
return MakeErrorJson(TEXT("Invalid JSON body"));
}
FString BlueprintName = Json->GetStringField(TEXT("blueprint"));
FString NewParentName = Json->GetStringField(TEXT("newParentClass"));
if (BlueprintName.IsEmpty() || NewParentName.IsEmpty())
{
return MakeErrorJson(TEXT("Missing required fields: blueprint, newParentClass"));
}
// Load Blueprint
FString LoadError;
UBlueprint* BP = LoadBlueprintByName(BlueprintName, LoadError);
if (!BP)
{
return MakeErrorJson(LoadError);
}
FString OldParentName = BP->ParentClass ? BP->ParentClass->GetName() : TEXT("None");
// Find the new parent class
// Try C++ class first (e.g. "WebUIHUD" finds /Script/ModuleName.WebUIHUD)
UClass* NewParentClass = nullptr;
// Search across all packages for native classes
for (TObjectIterator<UClass> It; It; ++It)
{
if (It->GetName() == NewParentName)
{
NewParentClass = *It;
break;
}
}
// If not found as C++ class, try loading as a Blueprint asset
if (!NewParentClass)
{
FString ParentLoadError;
UBlueprint* ParentBP = LoadBlueprintByName(NewParentName, ParentLoadError);
if (ParentBP && ParentBP->GeneratedClass)
{
NewParentClass = ParentBP->GeneratedClass;
}
}
if (!NewParentClass)
{
return MakeErrorJson(FString::Printf(
TEXT("Could not find class '%s'. Provide a C++ class name (e.g. 'WebUIHUD') or Blueprint name."),
*NewParentName));
}
// Validate: new parent must be compatible
if (BP->ParentClass && !NewParentClass->IsChildOf(BP->ParentClass->GetSuperClass()) &&
BP->ParentClass != NewParentClass)
{
// Just warn, don't block — the user may intentionally reparent to a sibling
UE_LOG(LogTemp, Warning,
TEXT("BlueprintMCP: Reparenting '%s' from '%s' to '%s' — classes are not in a direct hierarchy"),
*BlueprintName, *OldParentName, *NewParentClass->GetName());
}
UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Reparenting '%s' from '%s' to '%s'"),
*BlueprintName, *OldParentName, *NewParentClass->GetName());
// Perform reparent
BP->ParentClass = NewParentClass;
// Refresh all nodes to pick up new parent's functions/variables
FBlueprintEditorUtils::RefreshAllNodes(BP);
// Compile
FKismetEditorUtilities::CompileBlueprint(BP);
// Save
bool bSaved = SaveBlueprintPackage(BP);
FString NewParentActualName = NewParentClass->GetName();
UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Reparent complete, save %s"),
bSaved ? TEXT("succeeded") : TEXT("failed"));
TSharedRef<FJsonObject> Result = MakeShared<FJsonObject>();
Result->SetBoolField(TEXT("success"), true);
Result->SetStringField(TEXT("blueprint"), BlueprintName);
Result->SetStringField(TEXT("oldParentClass"), OldParentName);
Result->SetStringField(TEXT("newParentClass"), NewParentActualName);
Result->SetBoolField(TEXT("saved"), bSaved);
return JsonToString(Result);
}
// ============================================================
// HandleCreateBlueprint — create a new Blueprint asset
// ============================================================
FString FBlueprintMCPServer::HandleCreateBlueprint(const FString& Body)
{
TSharedPtr<FJsonObject> Json = ParseBodyJson(Body);
if (!Json.IsValid())
{
return MakeErrorJson(TEXT("Invalid JSON body"));
}
FString BlueprintName = Json->GetStringField(TEXT("blueprintName"));
FString PackagePath = Json->GetStringField(TEXT("packagePath"));
FString ParentClassName = Json->GetStringField(TEXT("parentClass"));
FString BlueprintTypeStr = Json->GetStringField(TEXT("blueprintType"));
if (BlueprintName.IsEmpty() || PackagePath.IsEmpty() || ParentClassName.IsEmpty())
{
return MakeErrorJson(TEXT("Missing required fields: blueprintName, packagePath, parentClass"));
}
// Validate packagePath starts with /Game
if (!PackagePath.StartsWith(TEXT("/Game")))
{
return MakeErrorJson(TEXT("packagePath must start with '/Game'"));
}
// Check if asset already exists
FString FullAssetPath = PackagePath / BlueprintName;
if (FindBlueprintAsset(BlueprintName) || FindBlueprintAsset(FullAssetPath))
{
return MakeErrorJson(FString::Printf(
TEXT("Blueprint '%s' already exists. Use a different name or delete the existing asset first."),
*BlueprintName));
}
// Resolve parent class — try C++ class first, then Blueprint
UClass* ParentClass = nullptr;
for (TObjectIterator<UClass> It; It; ++It)
{
if (It->GetName() == ParentClassName)
{
ParentClass = *It;
break;
}
}
if (!ParentClass)
{
FString ParentLoadError;
UBlueprint* ParentBP = LoadBlueprintByName(ParentClassName, ParentLoadError);
if (ParentBP && ParentBP->GeneratedClass)
{
ParentClass = ParentBP->GeneratedClass;
}
}
if (!ParentClass)
{
return MakeErrorJson(FString::Printf(
TEXT("Could not find parent class '%s'. Provide a C++ class name (e.g. 'Actor', 'Pawn') or Blueprint name."),
*ParentClassName));
}
// Map blueprintType string to EBlueprintType
EBlueprintType BlueprintType = BPTYPE_Normal;
if (!BlueprintTypeStr.IsEmpty())
{
if (BlueprintTypeStr == TEXT("Interface"))
{
BlueprintType = BPTYPE_Interface;
}
else if (BlueprintTypeStr == TEXT("FunctionLibrary"))
{
BlueprintType = BPTYPE_FunctionLibrary;
}
else if (BlueprintTypeStr == TEXT("MacroLibrary"))
{
BlueprintType = BPTYPE_MacroLibrary;
}
else if (BlueprintTypeStr != TEXT("Normal"))
{
return MakeErrorJson(FString::Printf(
TEXT("Invalid blueprintType '%s'. Valid values: Normal, Interface, FunctionLibrary, MacroLibrary"),
*BlueprintTypeStr));
}
}
// For Interface type, parent must be UInterface
if (BlueprintType == BPTYPE_Interface && !ParentClass->IsChildOf(UInterface::StaticClass()))
{
// Use the engine's standard BlueprintInterface parent
ParentClass = UInterface::StaticClass();
}
UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Creating Blueprint '%s' in '%s' with parent '%s' (type=%s)"),
*BlueprintName, *PackagePath, *ParentClass->GetName(), *BlueprintTypeStr);
// Create the package
FString FullPackagePath = PackagePath / BlueprintName;
UPackage* Package = CreatePackage(*FullPackagePath);
if (!Package)
{
return MakeErrorJson(FString::Printf(TEXT("Failed to create package at '%s'"), *FullPackagePath));
}
// Create the Blueprint
UBlueprint* NewBP = FKismetEditorUtilities::CreateBlueprint(
ParentClass,
Package,
FName(*BlueprintName),
BlueprintType,
UBlueprint::StaticClass(),
UBlueprintGeneratedClass::StaticClass()
);
if (!NewBP)
{
return MakeErrorJson(TEXT("FKismetEditorUtilities::CreateBlueprint returned null"));
}
// Compile
FKismetEditorUtilities::CompileBlueprint(NewBP);
// Save
bool bSaved = SaveBlueprintPackage(NewBP);
// Refresh asset cache
FAssetRegistryModule& ARM = FModuleManager::LoadModuleChecked<FAssetRegistryModule>("AssetRegistry");
AllBlueprintAssets.Empty();
ARM.Get().GetAssetsByClass(UBlueprint::StaticClass()->GetClassPathName(), AllBlueprintAssets, true);
// Collect graph names
TArray<TSharedPtr<FJsonValue>> GraphNames;
for (UEdGraph* Graph : NewBP->UbergraphPages)
{
GraphNames.Add(MakeShared<FJsonValueString>(Graph->GetName()));
}
for (UEdGraph* Graph : NewBP->FunctionGraphs)
{
GraphNames.Add(MakeShared<FJsonValueString>(Graph->GetName()));
}
for (UEdGraph* Graph : NewBP->MacroGraphs)
{
GraphNames.Add(MakeShared<FJsonValueString>(Graph->GetName()));
}
UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Created Blueprint '%s' with %d graphs (saved: %s)"),
*BlueprintName, GraphNames.Num(), bSaved ? TEXT("true") : TEXT("false"));
TSharedRef<FJsonObject> Result = MakeShared<FJsonObject>();
Result->SetBoolField(TEXT("success"), true);
Result->SetStringField(TEXT("blueprintName"), BlueprintName);
Result->SetStringField(TEXT("packagePath"), PackagePath);
Result->SetStringField(TEXT("assetPath"), FullAssetPath);
Result->SetStringField(TEXT("parentClass"), ParentClass->GetName());
Result->SetStringField(TEXT("blueprintType"), BlueprintTypeStr.IsEmpty() ? TEXT("Normal") : BlueprintTypeStr);
Result->SetBoolField(TEXT("saved"), bSaved);
Result->SetArrayField(TEXT("graphs"), GraphNames);
return JsonToString(Result);
}
// ============================================================
// HandleCreateGraph — create a new function, macro, or custom event graph
// ============================================================
FString FBlueprintMCPServer::HandleCreateGraph(const FString& Body)
{
TSharedPtr<FJsonObject> Json = ParseBodyJson(Body);
if (!Json.IsValid())
{
return MakeErrorJson(TEXT("Invalid JSON body"));
}
FString BlueprintName = Json->GetStringField(TEXT("blueprint"));
FString GraphName = Json->GetStringField(TEXT("graphName"));
FString GraphType = Json->GetStringField(TEXT("graphType"));
if (BlueprintName.IsEmpty() || GraphName.IsEmpty() || GraphType.IsEmpty())
{
return MakeErrorJson(TEXT("Missing required fields: blueprint, graphName, graphType"));
}
if (GraphType != TEXT("function") && GraphType != TEXT("macro") && GraphType != TEXT("customEvent"))
{
return MakeErrorJson(FString::Printf(
TEXT("Invalid graphType '%s'. Valid values: function, macro, customEvent"), *GraphType));
}
// Load Blueprint
FString LoadError;
UBlueprint* BP = LoadBlueprintByName(BlueprintName, LoadError);
if (!BP)
{
return MakeErrorJson(LoadError);
}
// Check graph name uniqueness
TArray<UEdGraph*> AllGraphs;
BP->GetAllGraphs(AllGraphs);
for (UEdGraph* Existing : AllGraphs)
{
if (Existing && Existing->GetName().Equals(GraphName, ESearchCase::IgnoreCase))
{
return MakeErrorJson(FString::Printf(
TEXT("A graph named '%s' already exists in Blueprint '%s'"), *GraphName, *BlueprintName));
}
}
// Also check for existing custom events with the same name
if (GraphType == TEXT("customEvent"))
{
for (UEdGraph* Graph : AllGraphs)
{
if (!Graph) continue;
for (UEdGraphNode* Node : Graph->Nodes)
{
if (UK2Node_CustomEvent* CE = Cast<UK2Node_CustomEvent>(Node))
{
if (CE->CustomFunctionName == FName(*GraphName))
{
return MakeErrorJson(FString::Printf(
TEXT("A custom event named '%s' already exists in Blueprint '%s'"), *GraphName, *BlueprintName));
}
}
}
}
}
UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Creating %s graph '%s' in Blueprint '%s'"),
*GraphType, *GraphName, *BlueprintName);
FString CreatedNodeId;
if (GraphType == TEXT("function"))
{
UEdGraph* NewGraph = FBlueprintEditorUtils::CreateNewGraph(BP, FName(*GraphName),
UEdGraph::StaticClass(), UEdGraphSchema_K2::StaticClass());
if (!NewGraph)
{
return MakeErrorJson(TEXT("Failed to create function graph"));
}
FBlueprintEditorUtils::AddFunctionGraph(BP, NewGraph, /*bIsUserCreated=*/true, /*SignatureFromObject=*/static_cast<UClass*>(nullptr));
}
else if (GraphType == TEXT("macro"))
{
UEdGraph* NewGraph = FBlueprintEditorUtils::CreateNewGraph(BP, FName(*GraphName),
UEdGraph::StaticClass(), UEdGraphSchema_K2::StaticClass());
if (!NewGraph)
{
return MakeErrorJson(TEXT("Failed to create macro graph"));
}
FBlueprintEditorUtils::AddMacroGraph(BP, NewGraph, /*bIsUserCreated=*/true, /*SignatureFromClass=*/nullptr);
}
else // customEvent
{
// Find the EventGraph (first UbergraphPage)
UEdGraph* EventGraph = nullptr;
if (BP->UbergraphPages.Num() > 0)
{
EventGraph = BP->UbergraphPages[0];
}
if (!EventGraph)
{
return MakeErrorJson(TEXT("Blueprint has no EventGraph to add a custom event to"));
}
// Create a custom event node in the EventGraph
UK2Node_CustomEvent* NewEvent = NewObject<UK2Node_CustomEvent>(EventGraph);
NewEvent->CustomFunctionName = FName(*GraphName);
NewEvent->bIsEditable = true;
EventGraph->AddNode(NewEvent, /*bFromUI=*/false, /*bSelectNewNode=*/false);
NewEvent->CreateNewGuid();
NewEvent->PostPlacedNewNode();
NewEvent->AllocateDefaultPins();
CreatedNodeId = NewEvent->NodeGuid.ToString();
}
FBlueprintEditorUtils::MarkBlueprintAsStructurallyModified(BP);
bool bSaved = SaveBlueprintPackage(BP);
UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Created %s graph '%s' in '%s' (saved: %s)"),
*GraphType, *GraphName, *BlueprintName, bSaved ? TEXT("true") : TEXT("false"));
TSharedRef<FJsonObject> Result = MakeShared<FJsonObject>();
Result->SetBoolField(TEXT("success"), true);
Result->SetStringField(TEXT("blueprint"), BlueprintName);
Result->SetStringField(TEXT("graphName"), GraphName);
Result->SetStringField(TEXT("graphType"), GraphType);
Result->SetBoolField(TEXT("saved"), bSaved);
if (!CreatedNodeId.IsEmpty())
{
Result->SetStringField(TEXT("nodeId"), CreatedNodeId);
}
return JsonToString(Result);
}
// ============================================================
// HandleDeleteGraph — delete a function or macro graph
// ============================================================
FString FBlueprintMCPServer::HandleDeleteGraph(const FString& Body)
{
TSharedPtr<FJsonObject> Json = ParseBodyJson(Body);
if (!Json.IsValid()) return MakeErrorJson(TEXT("Invalid JSON body"));
FString BlueprintName = Json->GetStringField(TEXT("blueprint"));
FString GraphName = Json->GetStringField(TEXT("graphName"));
if (BlueprintName.IsEmpty() || GraphName.IsEmpty())
return MakeErrorJson(TEXT("Missing required fields: blueprint, graphName"));
FString LoadError;
UBlueprint* BP = LoadBlueprintByName(BlueprintName, LoadError);
if (!BP) return MakeErrorJson(LoadError);
// Find the graph
UEdGraph* TargetGraph = nullptr;
FString GraphType;
for (UEdGraph* Graph : BP->FunctionGraphs)
{
if (Graph && Graph->GetName().Equals(GraphName, ESearchCase::IgnoreCase))
{
TargetGraph = Graph;
GraphType = TEXT("function");
break;
}
}
if (!TargetGraph)
{
for (UEdGraph* Graph : BP->MacroGraphs)
{
if (Graph && Graph->GetName().Equals(GraphName, ESearchCase::IgnoreCase))
{
TargetGraph = Graph;
GraphType = TEXT("macro");
break;
}
}
}
// Check if it's an UbergraphPage (EventGraph) — disallow deletion
if (!TargetGraph)
{
for (UEdGraph* Graph : BP->UbergraphPages)
{
if (Graph && Graph->GetName().Equals(GraphName, ESearchCase::IgnoreCase))
{
return MakeErrorJson(FString::Printf(
TEXT("Cannot delete UbergraphPage '%s'. EventGraph and other Ubergraph pages cannot be deleted."),
*GraphName));
}
}
return MakeErrorJson(FString::Printf(TEXT("Graph '%s' not found in Blueprint '%s'"), *GraphName, *BlueprintName));
}
UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Deleting %s graph '%s' from Blueprint '%s'"),
*GraphType, *GraphName, *BlueprintName);
// Count nodes for reporting
int32 NodeCount = TargetGraph->Nodes.Num();
// Remove the graph
FBlueprintEditorUtils::RemoveGraph(BP, TargetGraph, EGraphRemoveFlags::Default);
FBlueprintEditorUtils::MarkBlueprintAsStructurallyModified(BP);
bool bSaved = SaveBlueprintPackage(BP);
UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Deleted graph '%s' (%d nodes), save %s"),
*GraphName, NodeCount, bSaved ? TEXT("true") : TEXT("false"));
TSharedRef<FJsonObject> Result = MakeShared<FJsonObject>();
Result->SetBoolField(TEXT("success"), true);
Result->SetStringField(TEXT("blueprint"), BlueprintName);
Result->SetStringField(TEXT("graphName"), GraphName);
Result->SetStringField(TEXT("graphType"), GraphType);
Result->SetNumberField(TEXT("nodeCount"), NodeCount);
Result->SetBoolField(TEXT("saved"), bSaved);
return JsonToString(Result);
}
// ============================================================
// HandleRenameGraph — rename a function or macro graph
// ============================================================
FString FBlueprintMCPServer::HandleRenameGraph(const FString& Body)
{
TSharedPtr<FJsonObject> Json = ParseBodyJson(Body);
if (!Json.IsValid()) return MakeErrorJson(TEXT("Invalid JSON body"));
FString BlueprintName = Json->GetStringField(TEXT("blueprint"));
FString GraphName = Json->GetStringField(TEXT("graphName"));
FString NewName = Json->GetStringField(TEXT("newName"));
if (BlueprintName.IsEmpty() || GraphName.IsEmpty() || NewName.IsEmpty())
return MakeErrorJson(TEXT("Missing required fields: blueprint, graphName, newName"));
FString LoadError;
UBlueprint* BP = LoadBlueprintByName(BlueprintName, LoadError);
if (!BP) return MakeErrorJson(LoadError);
// Check if it's an UbergraphPage — disallow rename
for (UEdGraph* Graph : BP->UbergraphPages)
{
if (Graph && Graph->GetName().Equals(GraphName, ESearchCase::IgnoreCase))
{
return MakeErrorJson(FString::Printf(
TEXT("Cannot rename UbergraphPage '%s'. EventGraph and other Ubergraph pages cannot be renamed."),
*GraphName));
}
}
// Find the graph in FunctionGraphs or MacroGraphs
UEdGraph* TargetGraph = nullptr;
FString GraphType;
for (UEdGraph* Graph : BP->FunctionGraphs)
{
if (Graph && Graph->GetName().Equals(GraphName, ESearchCase::IgnoreCase))
{
TargetGraph = Graph;
GraphType = TEXT("function");
break;
}
}
if (!TargetGraph)
{
for (UEdGraph* Graph : BP->MacroGraphs)
{
if (Graph && Graph->GetName().Equals(GraphName, ESearchCase::IgnoreCase))
{
TargetGraph = Graph;
GraphType = TEXT("macro");
break;
}
}
}
if (!TargetGraph)
return MakeErrorJson(FString::Printf(TEXT("Graph '%s' not found in Blueprint '%s'"), *GraphName, *BlueprintName));
// Check for name collision
TArray<UEdGraph*> AllGraphs;
BP->GetAllGraphs(AllGraphs);
for (UEdGraph* Existing : AllGraphs)
{
if (Existing && Existing != TargetGraph && Existing->GetName().Equals(NewName, ESearchCase::IgnoreCase))
{
return MakeErrorJson(FString::Printf(
TEXT("A graph named '%s' already exists in Blueprint '%s'"), *NewName, *BlueprintName));
}
}
UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Renaming %s graph '%s' to '%s' in Blueprint '%s'"),
*GraphType, *GraphName, *NewName, *BlueprintName);
FBlueprintEditorUtils::RenameGraph(TargetGraph, NewName);
FBlueprintEditorUtils::MarkBlueprintAsStructurallyModified(BP);
bool bSaved = SaveBlueprintPackage(BP);
UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Renamed graph '%s' to '%s', save %s"),
*GraphName, *NewName, bSaved ? TEXT("true") : TEXT("false"));
TSharedRef<FJsonObject> Result = MakeShared<FJsonObject>();
Result->SetBoolField(TEXT("success"), true);
Result->SetStringField(TEXT("blueprint"), BlueprintName);
Result->SetStringField(TEXT("oldName"), GraphName);
Result->SetStringField(TEXT("newName"), TargetGraph->GetName());
Result->SetStringField(TEXT("graphType"), GraphType);
Result->SetBoolField(TEXT("saved"), bSaved);
return JsonToString(Result);
}

View File

@@ -0,0 +1,310 @@
#include "BlueprintMCPServer.h"
#include "Engine/Blueprint.h"
#include "EdGraph/EdGraph.h"
#include "Kismet2/BlueprintEditorUtils.h"
#include "Kismet2/KismetEditorUtilities.h"
#include "Serialization/JsonReader.h"
#include "Serialization/JsonWriter.h"
#include "Serialization/JsonSerializer.h"
#include "UObject/UObjectIterator.h"
// ============================================================
// HandleListInterfaces — list implemented interfaces on a Blueprint
// ============================================================
FString FBlueprintMCPServer::HandleListInterfaces(const FString& Body)
{
TSharedPtr<FJsonObject> Json = ParseBodyJson(Body);
if (!Json.IsValid())
{
return MakeErrorJson(TEXT("Invalid JSON body"));
}
FString BlueprintName = Json->GetStringField(TEXT("blueprint"));
if (BlueprintName.IsEmpty())
{
return MakeErrorJson(TEXT("Missing required field: blueprint"));
}
FString LoadError;
UBlueprint* BP = LoadBlueprintByName(BlueprintName, LoadError);
if (!BP)
{
return MakeErrorJson(LoadError);
}
TArray<TSharedPtr<FJsonValue>> InterfacesArr;
for (const FBPInterfaceDescription& IfaceDesc : BP->ImplementedInterfaces)
{
if (!IfaceDesc.Interface)
{
continue;
}
TSharedRef<FJsonObject> IfaceObj = MakeShared<FJsonObject>();
IfaceObj->SetStringField(TEXT("name"), IfaceDesc.Interface->GetName());
IfaceObj->SetStringField(TEXT("classPath"), IfaceDesc.Interface->GetPathName());
// Collect function graph names from the interface
TArray<TSharedPtr<FJsonValue>> FuncArr;
for (const UEdGraph* Graph : IfaceDesc.Graphs)
{
if (Graph)
{
FuncArr.Add(MakeShared<FJsonValueString>(Graph->GetName()));
}
}
IfaceObj->SetArrayField(TEXT("functions"), FuncArr);
InterfacesArr.Add(MakeShared<FJsonValueObject>(IfaceObj));
}
TSharedRef<FJsonObject> Result = MakeShared<FJsonObject>();
Result->SetStringField(TEXT("blueprint"), BlueprintName);
Result->SetNumberField(TEXT("count"), InterfacesArr.Num());
Result->SetArrayField(TEXT("interfaces"), InterfacesArr);
return JsonToString(Result);
}
// ============================================================
// HandleAddInterface — add a Blueprint Interface implementation
// ============================================================
FString FBlueprintMCPServer::HandleAddInterface(const FString& Body)
{
TSharedPtr<FJsonObject> Json = ParseBodyJson(Body);
if (!Json.IsValid())
{
return MakeErrorJson(TEXT("Invalid JSON body"));
}
FString BlueprintName = Json->GetStringField(TEXT("blueprint"));
FString InterfaceName = Json->GetStringField(TEXT("interfaceName"));
if (BlueprintName.IsEmpty() || InterfaceName.IsEmpty())
{
return MakeErrorJson(TEXT("Missing required fields: blueprint, interfaceName"));
}
FString LoadError;
UBlueprint* BP = LoadBlueprintByName(BlueprintName, LoadError);
if (!BP)
{
return MakeErrorJson(LoadError);
}
// Resolve the interface class
UClass* InterfaceClass = nullptr;
// Strategy 1: Search loaded UInterface classes by name
for (TObjectIterator<UClass> 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 = LoadBlueprintByName(InterfaceName, IfaceLoadError);
if (IfaceBP && IfaceBP->GeneratedClass && IfaceBP->GeneratedClass->IsChildOf(UInterface::StaticClass()))
{
InterfaceClass = IfaceBP->GeneratedClass;
}
}
if (!InterfaceClass)
{
return MakeErrorJson(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 MakeErrorJson(FString::Printf(
TEXT("Interface '%s' is already implemented by Blueprint '%s'"),
*InterfaceName, *BlueprintName));
}
}
// Get interface class path for the non-deprecated overload
FTopLevelAssetPath InterfacePath = InterfaceClass->GetClassPathName();
UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Adding interface '%s' to Blueprint '%s'"),
*InterfaceClass->GetName(), *BlueprintName);
bool bAdded = FBlueprintEditorUtils::ImplementNewInterface(BP, InterfacePath);
if (!bAdded)
{
return MakeErrorJson(FString::Printf(
TEXT("FBlueprintEditorUtils::ImplementNewInterface failed for interface '%s' on Blueprint '%s'"),
*InterfaceName, *BlueprintName));
}
// Collect stub function graph names from the newly added interface entry
TArray<FString> 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);
bool bSaved = SaveBlueprintPackage(BP);
UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Added interface '%s' to '%s' (%d function stubs, saved: %s)"),
*InterfaceClass->GetName(), *BlueprintName, AddedFunctions.Num(), bSaved ? TEXT("true") : TEXT("false"));
TSharedRef<FJsonObject> Result = MakeShared<FJsonObject>();
Result->SetBoolField(TEXT("success"), true);
Result->SetStringField(TEXT("blueprint"), BlueprintName);
Result->SetStringField(TEXT("interfaceName"), InterfaceClass->GetName());
Result->SetStringField(TEXT("interfacePath"), InterfaceClass->GetPathName());
TArray<TSharedPtr<FJsonValue>> FuncArr;
for (const FString& FuncName : AddedFunctions)
{
FuncArr.Add(MakeShared<FJsonValueString>(FuncName));
}
Result->SetArrayField(TEXT("functionGraphsAdded"), FuncArr);
Result->SetBoolField(TEXT("saved"), bSaved);
return JsonToString(Result);
}
// ============================================================
// HandleRemoveInterface — remove a Blueprint Interface implementation
// ============================================================
FString FBlueprintMCPServer::HandleRemoveInterface(const FString& Body)
{
TSharedPtr<FJsonObject> Json = ParseBodyJson(Body);
if (!Json.IsValid())
{
return MakeErrorJson(TEXT("Invalid JSON body"));
}
FString BlueprintName = Json->GetStringField(TEXT("blueprint"));
FString InterfaceName = Json->GetStringField(TEXT("interfaceName"));
if (BlueprintName.IsEmpty() || InterfaceName.IsEmpty())
{
return MakeErrorJson(TEXT("Missing required fields: blueprint, interfaceName"));
}
bool bPreserveFunctions = false;
if (Json->HasField(TEXT("preserveFunctions")))
{
bPreserveFunctions = Json->GetBoolField(TEXT("preserveFunctions"));
}
FString LoadError;
UBlueprint* BP = LoadBlueprintByName(BlueprintName, LoadError);
if (!BP)
{
return MakeErrorJson(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<TSharedPtr<FJsonValue>> IfaceList;
for (const FBPInterfaceDescription& IfaceDesc : BP->ImplementedInterfaces)
{
if (IfaceDesc.Interface)
{
IfaceList.Add(MakeShared<FJsonValueString>(IfaceDesc.Interface->GetName()));
}
}
TSharedRef<FJsonObject> ErrorResult = MakeShared<FJsonObject>();
ErrorResult->SetStringField(TEXT("error"), FString::Printf(
TEXT("Interface '%s' is not implemented by Blueprint '%s'"),
*InterfaceName, *BlueprintName));
ErrorResult->SetArrayField(TEXT("implementedInterfaces"), IfaceList);
return JsonToString(ErrorResult);
}
FTopLevelAssetPath InterfacePath = FoundInterface->GetClassPathName();
UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Removing interface '%s' from Blueprint '%s' (preserveFunctions: %s)"),
*FoundInterface->GetName(), *BlueprintName, bPreserveFunctions ? TEXT("true") : TEXT("false"));
FBlueprintEditorUtils::RemoveInterface(BP, InterfacePath, bPreserveFunctions);
FBlueprintEditorUtils::MarkBlueprintAsStructurallyModified(BP);
bool bSaved = SaveBlueprintPackage(BP);
UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Removed interface '%s' from '%s' (saved: %s)"),
*FoundInterface->GetName(), *BlueprintName, bSaved ? TEXT("true") : TEXT("false"));
TSharedRef<FJsonObject> Result = MakeShared<FJsonObject>();
Result->SetBoolField(TEXT("success"), true);
Result->SetStringField(TEXT("blueprint"), BlueprintName);
Result->SetStringField(TEXT("interfaceName"), FoundInterface->GetName());
Result->SetBoolField(TEXT("preservedFunctions"), bPreserveFunctions);
Result->SetBoolField(TEXT("saved"), bSaved);
return JsonToString(Result);
}

View File

@@ -0,0 +1,736 @@
#include "BlueprintMCPServer.h"
#include "Materials/Material.h"
#include "Materials/MaterialInterface.h"
#include "Materials/MaterialInstanceConstant.h"
#include "Materials/MaterialExpressionScalarParameter.h"
#include "Materials/MaterialExpressionVectorParameter.h"
#include "Materials/MaterialExpressionTextureSampleParameter2D.h"
#include "Materials/MaterialExpressionStaticSwitchParameter.h"
#include "Factories/MaterialInstanceConstantFactoryNew.h"
#include "AssetToolsModule.h"
#include "IAssetTools.h"
#include "AssetRegistry/AssetRegistryModule.h"
#include "Serialization/JsonReader.h"
#include "Serialization/JsonWriter.h"
#include "Serialization/JsonSerializer.h"
#include "UObject/SavePackage.h"
#include "Engine/Texture.h"
// ============================================================
// HandleCreateMaterialInstance — create a new Material Instance Constant
// ============================================================
FString FBlueprintMCPServer::HandleCreateMaterialInstance(const FString& Body)
{
TSharedPtr<FJsonObject> Json = ParseBodyJson(Body);
if (!Json.IsValid())
{
return MakeErrorJson(TEXT("Invalid JSON body"));
}
FString Name = Json->GetStringField(TEXT("name"));
FString PackagePath = Json->GetStringField(TEXT("packagePath"));
FString ParentMaterialName = Json->GetStringField(TEXT("parentMaterial"));
if (Name.IsEmpty() || PackagePath.IsEmpty() || ParentMaterialName.IsEmpty())
{
return MakeErrorJson(TEXT("Missing required fields: name, packagePath, parentMaterial"));
}
// Validate packagePath starts with /Game
if (!PackagePath.StartsWith(TEXT("/Game")))
{
return MakeErrorJson(TEXT("packagePath must start with '/Game'"));
}
// Check if asset already exists
FString FullAssetPath = PackagePath / Name;
if (FindMaterialInstanceAsset(Name) || FindMaterialInstanceAsset(FullAssetPath))
{
return MakeErrorJson(FString::Printf(
TEXT("Material Instance '%s' already exists. Use a different name or delete the existing asset first."),
*Name));
}
// Load parent material — try as Material first, then as Material Instance
UMaterialInterface* ParentMaterial = nullptr;
{
FString LoadError;
UMaterial* ParentMat = LoadMaterialByName(ParentMaterialName, LoadError);
if (ParentMat)
{
ParentMaterial = ParentMat;
}
else
{
FString MILoadError;
UMaterialInstanceConstant* ParentMI = LoadMaterialInstanceByName(ParentMaterialName, MILoadError);
if (ParentMI)
{
ParentMaterial = ParentMI;
}
}
}
if (!ParentMaterial)
{
// Also try LoadObject as a fallback with the raw path
ParentMaterial = LoadObject<UMaterialInterface>(nullptr, *ParentMaterialName);
}
if (!ParentMaterial)
{
return MakeErrorJson(FString::Printf(
TEXT("Parent material '%s' not found. Provide a Material or Material Instance name/path."),
*ParentMaterialName));
}
UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Creating Material Instance '%s' in '%s' with parent '%s'"),
*Name, *PackagePath, *ParentMaterial->GetName());
// Create via factory + AssetTools
IAssetTools& AssetTools = FModuleManager::LoadModuleChecked<FAssetToolsModule>("AssetTools").Get();
UMaterialInstanceConstantFactoryNew* Factory = NewObject<UMaterialInstanceConstantFactoryNew>();
UObject* NewAsset = AssetTools.CreateAsset(Name, PackagePath, UMaterialInstanceConstant::StaticClass(), Factory);
if (!NewAsset)
{
return MakeErrorJson(FString::Printf(TEXT("Failed to create Material Instance asset '%s' in '%s'"), *Name, *PackagePath));
}
UMaterialInstanceConstant* MI = Cast<UMaterialInstanceConstant>(NewAsset);
if (!MI)
{
return MakeErrorJson(TEXT("Created asset is not a UMaterialInstanceConstant"));
}
// Set parent
MI->PreEditChange(nullptr);
MI->Parent = ParentMaterial;
MI->PostEditChange();
// Save
bool bSaved = SaveGenericPackage(MI);
// Refresh asset cache
FAssetRegistryModule& ARM = FModuleManager::LoadModuleChecked<FAssetRegistryModule>("AssetRegistry");
AllMaterialInstanceAssets.Empty();
ARM.Get().GetAssetsByClass(UMaterialInstanceConstant::StaticClass()->GetClassPathName(), AllMaterialInstanceAssets, false);
UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Created Material Instance '%s' with parent '%s' (saved: %s)"),
*Name, *ParentMaterial->GetName(), bSaved ? TEXT("true") : TEXT("false"));
TSharedRef<FJsonObject> Result = MakeShared<FJsonObject>();
Result->SetBoolField(TEXT("success"), true);
Result->SetStringField(TEXT("name"), Name);
Result->SetStringField(TEXT("path"), MI->GetPathName());
Result->SetStringField(TEXT("parent"), ParentMaterial->GetPathName());
Result->SetBoolField(TEXT("saved"), bSaved);
return JsonToString(Result);
}
// ============================================================
// HandleSetMaterialInstanceParameter — set a parameter override on an MI
// ============================================================
FString FBlueprintMCPServer::HandleSetMaterialInstanceParameter(const FString& Body)
{
TSharedPtr<FJsonObject> Json = ParseBodyJson(Body);
if (!Json.IsValid())
{
return MakeErrorJson(TEXT("Invalid JSON body"));
}
FString MIName = Json->GetStringField(TEXT("materialInstance"));
FString ParamName = Json->GetStringField(TEXT("parameterName"));
if (MIName.IsEmpty() || ParamName.IsEmpty())
{
return MakeErrorJson(TEXT("Missing required fields: materialInstance, parameterName"));
}
if (!Json->HasField(TEXT("value")))
{
return MakeErrorJson(TEXT("Missing required field: value"));
}
bool bDryRun = false;
if (Json->HasField(TEXT("dryRun")))
{
bDryRun = Json->GetBoolField(TEXT("dryRun"));
}
// Load the Material Instance
FString LoadError;
UMaterialInstanceConstant* MI = LoadMaterialInstanceByName(MIName, LoadError);
if (!MI)
{
return MakeErrorJson(LoadError);
}
// Determine the parameter type — explicit or auto-detect from parent
FString TypeStr;
if (Json->HasField(TEXT("type")))
{
TypeStr = Json->GetStringField(TEXT("type"));
}
// Auto-detect type from parent material's parameters if not provided
if (TypeStr.IsEmpty())
{
UMaterialInterface* ParentMat = MI->Parent;
while (ParentMat)
{
UMaterial* BaseMat = ParentMat->GetMaterial();
if (BaseMat)
{
// Check scalar parameters
for (UMaterialExpression* Expr : BaseMat->GetExpressions())
{
if (auto* SP = Cast<UMaterialExpressionScalarParameter>(Expr))
{
if (SP->ParameterName.ToString() == ParamName)
{
TypeStr = TEXT("scalar");
break;
}
}
else if (auto* VP = Cast<UMaterialExpressionVectorParameter>(Expr))
{
if (VP->ParameterName.ToString() == ParamName)
{
TypeStr = TEXT("vector");
break;
}
}
else if (auto* TP = Cast<UMaterialExpressionTextureSampleParameter2D>(Expr))
{
if (TP->ParameterName.ToString() == ParamName)
{
TypeStr = TEXT("texture");
break;
}
}
else if (auto* SSP = Cast<UMaterialExpressionStaticSwitchParameter>(Expr))
{
if (SSP->ParameterName.ToString() == ParamName)
{
TypeStr = TEXT("staticSwitch");
break;
}
}
}
break; // Only need to check the base material
}
// Walk up the parent chain if it's an MI parented to another MI
UMaterialInstanceConstant* ParentMI = Cast<UMaterialInstanceConstant>(ParentMat);
if (ParentMI)
{
ParentMat = ParentMI->Parent;
}
else
{
break;
}
}
}
if (TypeStr.IsEmpty())
{
return MakeErrorJson(FString::Printf(
TEXT("Could not determine parameter type for '%s'. Specify the 'type' field explicitly (scalar, vector, texture, staticSwitch)."),
*ParamName));
}
FString NewValueDescription;
FMaterialParameterInfo ParamInfo(*ParamName);
UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: %s parameter '%s' (type=%s) on Material Instance '%s'"),
bDryRun ? TEXT("[DRY RUN] Setting") : TEXT("Setting"),
*ParamName, *TypeStr, *MIName);
if (TypeStr.Equals(TEXT("scalar"), ESearchCase::IgnoreCase))
{
// Scalar parameter — value is a number
double FloatValue = Json->GetNumberField(TEXT("value"));
if (!bDryRun)
{
MI->SetScalarParameterValueEditorOnly(ParamInfo, (float)FloatValue);
}
NewValueDescription = FString::Printf(TEXT("%f"), FloatValue);
}
else if (TypeStr.Equals(TEXT("vector"), ESearchCase::IgnoreCase))
{
// Vector parameter — value is { r, g, b, a? }
const TSharedPtr<FJsonObject>* ValueObj = nullptr;
if (!Json->TryGetObjectField(TEXT("value"), ValueObj) || !ValueObj || !(*ValueObj).IsValid())
{
return MakeErrorJson(TEXT("For vector parameters, 'value' must be an object with r, g, b (and optional a) fields."));
}
double R = (*ValueObj)->GetNumberField(TEXT("r"));
double G = (*ValueObj)->GetNumberField(TEXT("g"));
double B = (*ValueObj)->GetNumberField(TEXT("b"));
double A = (*ValueObj)->HasField(TEXT("a")) ? (*ValueObj)->GetNumberField(TEXT("a")) : 1.0;
FLinearColor Color((float)R, (float)G, (float)B, (float)A);
if (!bDryRun)
{
MI->SetVectorParameterValueEditorOnly(ParamInfo, Color);
}
NewValueDescription = FString::Printf(TEXT("(R=%f, G=%f, B=%f, A=%f)"), R, G, B, A);
}
else if (TypeStr.Equals(TEXT("texture"), ESearchCase::IgnoreCase))
{
// Texture parameter — value is a texture path string
FString TexturePath = Json->GetStringField(TEXT("value"));
if (TexturePath.IsEmpty())
{
return MakeErrorJson(TEXT("For texture parameters, 'value' must be a texture asset path string."));
}
UTexture* TextureObj = LoadObject<UTexture>(nullptr, *TexturePath);
if (!TextureObj)
{
return MakeErrorJson(FString::Printf(TEXT("Could not load texture at path '%s'"), *TexturePath));
}
if (!bDryRun)
{
MI->SetTextureParameterValueEditorOnly(ParamInfo, TextureObj);
}
NewValueDescription = TexturePath;
}
else if (TypeStr.Equals(TEXT("staticSwitch"), ESearchCase::IgnoreCase))
{
// Static switch parameter — value is a bool
bool bSwitchValue = Json->GetBoolField(TEXT("value"));
if (!bDryRun)
{
// Modify static parameters
FStaticParameterSet StaticParams;
MI->GetStaticParameterValues(StaticParams);
bool bFound = false;
for (FStaticSwitchParameter& Param : StaticParams.StaticSwitchParameters)
{
if (Param.ParameterInfo.Name == FName(*ParamName))
{
Param.Value = bSwitchValue;
Param.bOverride = true;
bFound = true;
break;
}
}
if (!bFound)
{
// Add new static switch parameter entry
FStaticSwitchParameter NewParam;
NewParam.ParameterInfo.Name = FName(*ParamName);
NewParam.Value = bSwitchValue;
NewParam.bOverride = true;
StaticParams.StaticSwitchParameters.Add(NewParam);
}
MI->UpdateStaticPermutation(StaticParams);
}
NewValueDescription = bSwitchValue ? TEXT("true") : TEXT("false");
}
else
{
return MakeErrorJson(FString::Printf(
TEXT("Unknown parameter type '%s'. Valid types: scalar, vector, texture, staticSwitch"),
*TypeStr));
}
if (!bDryRun)
{
MI->PreEditChange(nullptr);
MI->PostEditChange();
MI->MarkPackageDirty();
SaveGenericPackage(MI);
}
UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: %s parameter '%s' = %s on '%s'"),
bDryRun ? TEXT("[DRY RUN] Would set") : TEXT("Set"),
*ParamName, *NewValueDescription, *MIName);
TSharedRef<FJsonObject> Result = MakeShared<FJsonObject>();
Result->SetBoolField(TEXT("success"), true);
Result->SetStringField(TEXT("materialInstance"), MIName);
Result->SetStringField(TEXT("parameterName"), ParamName);
Result->SetStringField(TEXT("type"), TypeStr);
Result->SetStringField(TEXT("newValue"), NewValueDescription);
if (bDryRun)
{
Result->SetBoolField(TEXT("dryRun"), true);
}
return JsonToString(Result);
}
// ============================================================
// HandleGetMaterialInstanceParameters — list all parameters on an MI
// ============================================================
FString FBlueprintMCPServer::HandleGetMaterialInstanceParameters(const TMap<FString, FString>& Params)
{
const FString* NameParam = Params.Find(TEXT("name"));
if (!NameParam || NameParam->IsEmpty())
{
return MakeErrorJson(TEXT("Missing required query parameter: name"));
}
FString LoadError;
UMaterialInstanceConstant* MI = LoadMaterialInstanceByName(*NameParam, LoadError);
if (!MI)
{
return MakeErrorJson(LoadError);
}
TSharedRef<FJsonObject> Result = MakeShared<FJsonObject>();
Result->SetStringField(TEXT("name"), MI->GetName());
Result->SetStringField(TEXT("path"), MI->GetPathName());
// Parent info
if (MI->Parent)
{
Result->SetStringField(TEXT("parent"), MI->Parent->GetPathName());
}
// Build parent chain
TArray<TSharedPtr<FJsonValue>> ParentChainArr;
{
UMaterialInterface* Current = MI->Parent;
while (Current)
{
TSharedRef<FJsonObject> ParentObj = MakeShared<FJsonObject>();
ParentObj->SetStringField(TEXT("name"), Current->GetName());
ParentObj->SetStringField(TEXT("path"), Current->GetPathName());
ParentObj->SetStringField(TEXT("class"), Current->GetClass()->GetName());
ParentChainArr.Add(MakeShared<FJsonValueObject>(ParentObj));
UMaterialInstanceConstant* ParentMI = Cast<UMaterialInstanceConstant>(Current);
if (ParentMI)
{
Current = ParentMI->Parent;
}
else
{
break; // Reached the root Material
}
}
}
Result->SetArrayField(TEXT("parentChain"), ParentChainArr);
// Scalar parameters
TArray<TSharedPtr<FJsonValue>> ScalarArr;
for (const FScalarParameterValue& Param : MI->ScalarParameterValues)
{
TSharedRef<FJsonObject> PObj = MakeShared<FJsonObject>();
PObj->SetStringField(TEXT("name"), Param.ParameterInfo.Name.ToString());
PObj->SetNumberField(TEXT("value"), Param.ParameterValue);
PObj->SetBoolField(TEXT("isOverridden"), true); // Present in ScalarParameterValues means it's overridden
ScalarArr.Add(MakeShared<FJsonValueObject>(PObj));
}
Result->SetArrayField(TEXT("scalarParameters"), ScalarArr);
// Vector parameters
TArray<TSharedPtr<FJsonValue>> VectorArr;
for (const FVectorParameterValue& Param : MI->VectorParameterValues)
{
TSharedRef<FJsonObject> PObj = MakeShared<FJsonObject>();
PObj->SetStringField(TEXT("name"), Param.ParameterInfo.Name.ToString());
PObj->SetNumberField(TEXT("r"), Param.ParameterValue.R);
PObj->SetNumberField(TEXT("g"), Param.ParameterValue.G);
PObj->SetNumberField(TEXT("b"), Param.ParameterValue.B);
PObj->SetNumberField(TEXT("a"), Param.ParameterValue.A);
PObj->SetBoolField(TEXT("isOverridden"), true);
VectorArr.Add(MakeShared<FJsonValueObject>(PObj));
}
Result->SetArrayField(TEXT("vectorParameters"), VectorArr);
// Texture parameters
TArray<TSharedPtr<FJsonValue>> TextureArr;
for (const FTextureParameterValue& Param : MI->TextureParameterValues)
{
TSharedRef<FJsonObject> PObj = MakeShared<FJsonObject>();
PObj->SetStringField(TEXT("name"), Param.ParameterInfo.Name.ToString());
if (Param.ParameterValue)
{
PObj->SetStringField(TEXT("texture"), Param.ParameterValue->GetPathName());
}
else
{
PObj->SetStringField(TEXT("texture"), TEXT("None"));
}
PObj->SetBoolField(TEXT("isOverridden"), true);
TextureArr.Add(MakeShared<FJsonValueObject>(PObj));
}
Result->SetArrayField(TEXT("textureParameters"), TextureArr);
// Static switch parameters
TArray<TSharedPtr<FJsonValue>> StaticSwitchArr;
{
FStaticParameterSet StaticParams;
MI->GetStaticParameterValues(StaticParams);
for (const FStaticSwitchParameter& Param : StaticParams.StaticSwitchParameters)
{
TSharedRef<FJsonObject> PObj = MakeShared<FJsonObject>();
PObj->SetStringField(TEXT("name"), Param.ParameterInfo.Name.ToString());
PObj->SetBoolField(TEXT("value"), Param.Value);
PObj->SetBoolField(TEXT("isOverridden"), Param.bOverride);
StaticSwitchArr.Add(MakeShared<FJsonValueObject>(PObj));
}
}
Result->SetArrayField(TEXT("staticSwitchParameters"), StaticSwitchArr);
// Also report inherited parameters from the parent material for discoverability
TArray<TSharedPtr<FJsonValue>> InheritedScalarArr;
TArray<TSharedPtr<FJsonValue>> InheritedVectorArr;
TArray<TSharedPtr<FJsonValue>> InheritedTextureArr;
TArray<TSharedPtr<FJsonValue>> InheritedStaticSwitchArr;
{
UMaterial* BaseMat = MI->GetMaterial();
if (BaseMat)
{
// Collect names of already-overridden parameters for filtering
TSet<FString> OverriddenScalars;
for (const FScalarParameterValue& P : MI->ScalarParameterValues)
{
OverriddenScalars.Add(P.ParameterInfo.Name.ToString());
}
TSet<FString> OverriddenVectors;
for (const FVectorParameterValue& P : MI->VectorParameterValues)
{
OverriddenVectors.Add(P.ParameterInfo.Name.ToString());
}
TSet<FString> OverriddenTextures;
for (const FTextureParameterValue& P : MI->TextureParameterValues)
{
OverriddenTextures.Add(P.ParameterInfo.Name.ToString());
}
TSet<FString> OverriddenStaticSwitches;
{
FStaticParameterSet SP;
MI->GetStaticParameterValues(SP);
for (const FStaticSwitchParameter& P : SP.StaticSwitchParameters)
{
if (P.bOverride)
{
OverriddenStaticSwitches.Add(P.ParameterInfo.Name.ToString());
}
}
}
for (UMaterialExpression* Expr : BaseMat->GetExpressions())
{
if (auto* SP = Cast<UMaterialExpressionScalarParameter>(Expr))
{
if (!OverriddenScalars.Contains(SP->ParameterName.ToString()))
{
TSharedRef<FJsonObject> PObj = MakeShared<FJsonObject>();
PObj->SetStringField(TEXT("name"), SP->ParameterName.ToString());
PObj->SetNumberField(TEXT("defaultValue"), SP->DefaultValue);
PObj->SetBoolField(TEXT("isOverridden"), false);
InheritedScalarArr.Add(MakeShared<FJsonValueObject>(PObj));
}
}
else if (auto* VP = Cast<UMaterialExpressionVectorParameter>(Expr))
{
if (!OverriddenVectors.Contains(VP->ParameterName.ToString()))
{
TSharedRef<FJsonObject> PObj = MakeShared<FJsonObject>();
PObj->SetStringField(TEXT("name"), VP->ParameterName.ToString());
PObj->SetNumberField(TEXT("r"), VP->DefaultValue.R);
PObj->SetNumberField(TEXT("g"), VP->DefaultValue.G);
PObj->SetNumberField(TEXT("b"), VP->DefaultValue.B);
PObj->SetNumberField(TEXT("a"), VP->DefaultValue.A);
PObj->SetBoolField(TEXT("isOverridden"), false);
InheritedVectorArr.Add(MakeShared<FJsonValueObject>(PObj));
}
}
else if (auto* TP = Cast<UMaterialExpressionTextureSampleParameter2D>(Expr))
{
if (!OverriddenTextures.Contains(TP->ParameterName.ToString()))
{
TSharedRef<FJsonObject> PObj = MakeShared<FJsonObject>();
PObj->SetStringField(TEXT("name"), TP->ParameterName.ToString());
if (TP->Texture)
{
PObj->SetStringField(TEXT("defaultTexture"), TP->Texture->GetPathName());
}
else
{
PObj->SetStringField(TEXT("defaultTexture"), TEXT("None"));
}
PObj->SetBoolField(TEXT("isOverridden"), false);
InheritedTextureArr.Add(MakeShared<FJsonValueObject>(PObj));
}
}
else if (auto* SSP = Cast<UMaterialExpressionStaticSwitchParameter>(Expr))
{
if (!OverriddenStaticSwitches.Contains(SSP->ParameterName.ToString()))
{
TSharedRef<FJsonObject> PObj = MakeShared<FJsonObject>();
PObj->SetStringField(TEXT("name"), SSP->ParameterName.ToString());
PObj->SetBoolField(TEXT("defaultValue"), SSP->DefaultValue);
PObj->SetBoolField(TEXT("isOverridden"), false);
InheritedStaticSwitchArr.Add(MakeShared<FJsonValueObject>(PObj));
}
}
}
}
}
// Merge inherited (non-overridden) params into the arrays
for (const TSharedPtr<FJsonValue>& V : InheritedScalarArr)
{
ScalarArr.Add(V);
}
for (const TSharedPtr<FJsonValue>& V : InheritedVectorArr)
{
VectorArr.Add(V);
}
for (const TSharedPtr<FJsonValue>& V : InheritedTextureArr)
{
TextureArr.Add(V);
}
for (const TSharedPtr<FJsonValue>& V : InheritedStaticSwitchArr)
{
StaticSwitchArr.Add(V);
}
// Update arrays with merged data
Result->SetArrayField(TEXT("scalarParameters"), ScalarArr);
Result->SetArrayField(TEXT("vectorParameters"), VectorArr);
Result->SetArrayField(TEXT("textureParameters"), TextureArr);
Result->SetArrayField(TEXT("staticSwitchParameters"), StaticSwitchArr);
return JsonToString(Result);
}
// ============================================================
// HandleReparentMaterialInstance — change parent of an MI
// ============================================================
FString FBlueprintMCPServer::HandleReparentMaterialInstance(const FString& Body)
{
TSharedPtr<FJsonObject> Json = ParseBodyJson(Body);
if (!Json.IsValid())
{
return MakeErrorJson(TEXT("Invalid JSON body"));
}
FString MIName = Json->GetStringField(TEXT("materialInstance"));
FString NewParentName = Json->GetStringField(TEXT("newParent"));
if (MIName.IsEmpty() || NewParentName.IsEmpty())
{
return MakeErrorJson(TEXT("Missing required fields: materialInstance, newParent"));
}
bool bDryRun = false;
if (Json->HasField(TEXT("dryRun")))
{
bDryRun = Json->GetBoolField(TEXT("dryRun"));
}
// Load the Material Instance
FString LoadError;
UMaterialInstanceConstant* MI = LoadMaterialInstanceByName(MIName, LoadError);
if (!MI)
{
return MakeErrorJson(LoadError);
}
// Capture old parent
FString OldParentPath = MI->Parent ? MI->Parent->GetPathName() : TEXT("None");
// Load new parent — try as Material first, then as Material Instance
UMaterialInterface* NewParent = nullptr;
{
FString MatLoadError;
UMaterial* NewParentMat = LoadMaterialByName(NewParentName, MatLoadError);
if (NewParentMat)
{
NewParent = NewParentMat;
}
else
{
FString MILoadError;
UMaterialInstanceConstant* NewParentMI = LoadMaterialInstanceByName(NewParentName, MILoadError);
if (NewParentMI)
{
NewParent = NewParentMI;
}
}
}
if (!NewParent)
{
// Try LoadObject as a fallback
NewParent = LoadObject<UMaterialInterface>(nullptr, *NewParentName);
}
if (!NewParent)
{
return MakeErrorJson(FString::Printf(
TEXT("New parent material '%s' not found. Provide a Material or Material Instance name/path."),
*NewParentName));
}
// Prevent circular parenting — check if NewParent is this MI or has this MI in its chain
{
UMaterialInterface* Check = NewParent;
while (Check)
{
if (Check == MI)
{
return MakeErrorJson(FString::Printf(
TEXT("Cannot reparent '%s' to '%s' — this would create a circular parent chain."),
*MIName, *NewParentName));
}
UMaterialInstanceConstant* CheckMI = Cast<UMaterialInstanceConstant>(Check);
if (CheckMI)
{
Check = CheckMI->Parent;
}
else
{
break;
}
}
}
UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: %s Material Instance '%s': parent '%s' -> '%s'"),
bDryRun ? TEXT("[DRY RUN] Reparenting") : TEXT("Reparenting"),
*MIName, *OldParentPath, *NewParent->GetPathName());
if (!bDryRun)
{
MI->PreEditChange(nullptr);
MI->Parent = NewParent;
MI->PostEditChange();
bool bSaved = SaveGenericPackage(MI);
UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Reparented Material Instance '%s' (saved: %s)"),
*MIName, bSaved ? TEXT("true") : TEXT("false"));
}
TSharedRef<FJsonObject> Result = MakeShared<FJsonObject>();
Result->SetBoolField(TEXT("success"), true);
Result->SetStringField(TEXT("materialInstance"), MIName);
Result->SetStringField(TEXT("oldParent"), OldParentPath);
Result->SetStringField(TEXT("newParent"), NewParent->GetPathName());
if (bDryRun)
{
Result->SetBoolField(TEXT("dryRun"), true);
}
return JsonToString(Result);
}

View File

@@ -0,0 +1,978 @@
#include "BlueprintMCPServer.h"
#include "Materials/Material.h"
#include "Materials/MaterialInstanceConstant.h"
#include "Materials/MaterialFunction.h"
#include "Materials/MaterialExpression.h"
#include "Materials/MaterialExpressionScalarParameter.h"
#include "Materials/MaterialExpressionVectorParameter.h"
#include "Materials/MaterialExpressionTextureObjectParameter.h"
#include "Materials/MaterialExpressionTextureSampleParameter2D.h"
#include "Materials/MaterialExpressionStaticSwitchParameter.h"
#include "Materials/MaterialExpressionConstant.h"
#include "Materials/MaterialExpressionConstant3Vector.h"
#include "Materials/MaterialExpressionConstant4Vector.h"
#include "Materials/MaterialExpressionTextureSample.h"
#include "Materials/MaterialExpressionTextureCoordinate.h"
#include "Materials/MaterialExpressionComponentMask.h"
#include "Materials/MaterialExpressionCustom.h"
#include "Materials/MaterialExpressionFunctionInput.h"
#include "Materials/MaterialExpressionFunctionOutput.h"
#include "Materials/MaterialExpressionMaterialFunctionCall.h"
#include "MaterialGraph/MaterialGraph.h"
#include "MaterialGraph/MaterialGraphNode.h"
#include "MaterialGraph/MaterialGraphNode_Root.h"
#include "MaterialGraph/MaterialGraphSchema.h"
#include "Kismet2/BlueprintEditorUtils.h"
#include "AssetRegistry/AssetRegistryModule.h"
#include "AssetRegistry/IAssetRegistry.h"
#include "Serialization/JsonWriter.h"
#include "Serialization/JsonSerializer.h"
#include "EdGraph/EdGraph.h"
#include "EdGraph/EdGraphNode.h"
// ============================================================
// HandleListMaterials — list Material and MaterialInstance assets
// ============================================================
FString FBlueprintMCPServer::HandleListMaterials(const TMap<FString, FString>& Params)
{
const FString* Filter = Params.Find(TEXT("filter"));
const FString* TypeFilter = Params.Find(TEXT("type"));
bool bIncludeMaterials = !TypeFilter || TypeFilter->IsEmpty() || *TypeFilter == TEXT("all") || *TypeFilter == TEXT("material");
bool bIncludeInstances = !TypeFilter || TypeFilter->IsEmpty() || *TypeFilter == TEXT("all") || *TypeFilter == TEXT("instance");
TArray<TSharedPtr<FJsonValue>> Entries;
if (bIncludeMaterials)
{
for (const FAssetData& Asset : AllMaterialAssets)
{
FString Name = Asset.AssetName.ToString();
FString Path = Asset.PackageName.ToString();
if (Filter && !Filter->IsEmpty())
{
if (!Name.Contains(*Filter, ESearchCase::IgnoreCase) &&
!Path.Contains(*Filter, ESearchCase::IgnoreCase))
{
continue;
}
}
TSharedRef<FJsonObject> Entry = MakeShared<FJsonObject>();
Entry->SetStringField(TEXT("name"), Name);
Entry->SetStringField(TEXT("path"), Path);
Entry->SetStringField(TEXT("type"), TEXT("Material"));
Entries.Add(MakeShared<FJsonValueObject>(Entry));
}
}
if (bIncludeInstances)
{
for (const FAssetData& Asset : AllMaterialInstanceAssets)
{
FString Name = Asset.AssetName.ToString();
FString Path = Asset.PackageName.ToString();
if (Filter && !Filter->IsEmpty())
{
if (!Name.Contains(*Filter, ESearchCase::IgnoreCase) &&
!Path.Contains(*Filter, ESearchCase::IgnoreCase))
{
continue;
}
}
TSharedRef<FJsonObject> Entry = MakeShared<FJsonObject>();
Entry->SetStringField(TEXT("name"), Name);
Entry->SetStringField(TEXT("path"), Path);
Entry->SetStringField(TEXT("type"), TEXT("MaterialInstance"));
Entries.Add(MakeShared<FJsonValueObject>(Entry));
}
}
int32 Total = AllMaterialAssets.Num() + AllMaterialInstanceAssets.Num();
TSharedRef<FJsonObject> Result = MakeShared<FJsonObject>();
Result->SetNumberField(TEXT("count"), Entries.Num());
Result->SetNumberField(TEXT("total"), Total);
Result->SetArrayField(TEXT("materials"), Entries);
return JsonToString(Result);
}
// ============================================================
// HandleGetMaterial — detailed info about a material or instance
// ============================================================
FString FBlueprintMCPServer::HandleGetMaterial(const TMap<FString, FString>& Params)
{
const FString* Name = Params.Find(TEXT("name"));
if (!Name || Name->IsEmpty())
{
return MakeErrorJson(TEXT("Missing 'name' parameter"));
}
FString DecodedName = UrlDecode(*Name);
// Try loading as UMaterial first
FString LoadError;
UMaterial* Material = LoadMaterialByName(DecodedName, LoadError);
if (Material)
{
UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: GetMaterial — loaded material '%s'"), *Material->GetName());
TSharedRef<FJsonObject> Result = MakeShared<FJsonObject>();
Result->SetStringField(TEXT("name"), Material->GetName());
Result->SetStringField(TEXT("path"), Material->GetPathName());
Result->SetStringField(TEXT("type"), TEXT("Material"));
// Material domain
FString DomainStr = TEXT("Unknown");
if (const UEnum* DomainEnum = StaticEnum<EMaterialDomain>())
{
DomainStr = DomainEnum->GetNameStringByValue((int64)Material->MaterialDomain);
}
Result->SetStringField(TEXT("domain"), DomainStr);
// Blend mode
FString BlendModeStr = TEXT("Unknown");
if (const UEnum* BlendEnum = StaticEnum<EBlendMode>())
{
BlendModeStr = BlendEnum->GetNameStringByValue((int64)Material->BlendMode);
}
Result->SetStringField(TEXT("blendMode"), BlendModeStr);
// Shading models
TArray<TSharedPtr<FJsonValue>> ShadingModels;
FMaterialShadingModelField SMField = Material->GetShadingModels();
if (const UEnum* SMEnum = StaticEnum<EMaterialShadingModel>())
{
for (int32 i = 0; i < SMEnum->NumEnums() - 1; ++i)
{
EMaterialShadingModel SM = (EMaterialShadingModel)SMEnum->GetValueByIndex(i);
if (SMField.HasShadingModel(SM))
{
ShadingModels.Add(MakeShared<FJsonValueString>(SMEnum->GetNameStringByIndex(i)));
}
}
}
Result->SetArrayField(TEXT("shadingModels"), ShadingModels);
// Two-sided
Result->SetBoolField(TEXT("twoSided"), Material->IsTwoSided());
// Expression count
auto Expressions = Material->GetExpressions();
Result->SetNumberField(TEXT("expressionCount"), Expressions.Num());
// Parameters — iterate expressions for parameter types
TArray<TSharedPtr<FJsonValue>> Parameters;
for (UMaterialExpression* Expr : Expressions)
{
if (!Expr) continue;
TSharedRef<FJsonObject> ParamObj = MakeShared<FJsonObject>();
bool bIsParam = false;
if (auto* SP = Cast<UMaterialExpressionScalarParameter>(Expr))
{
bIsParam = true;
ParamObj->SetStringField(TEXT("name"), SP->ParameterName.ToString());
ParamObj->SetStringField(TEXT("type"), TEXT("Scalar"));
ParamObj->SetStringField(TEXT("group"), SP->Group.ToString());
ParamObj->SetNumberField(TEXT("defaultValue"), SP->DefaultValue);
}
else if (auto* VP = Cast<UMaterialExpressionVectorParameter>(Expr))
{
bIsParam = true;
ParamObj->SetStringField(TEXT("name"), VP->ParameterName.ToString());
ParamObj->SetStringField(TEXT("type"), TEXT("Vector"));
ParamObj->SetStringField(TEXT("group"), VP->Group.ToString());
TSharedRef<FJsonObject> DefVal = MakeShared<FJsonObject>();
DefVal->SetNumberField(TEXT("r"), VP->DefaultValue.R);
DefVal->SetNumberField(TEXT("g"), VP->DefaultValue.G);
DefVal->SetNumberField(TEXT("b"), VP->DefaultValue.B);
DefVal->SetNumberField(TEXT("a"), VP->DefaultValue.A);
ParamObj->SetObjectField(TEXT("defaultValue"), DefVal);
}
else if (auto* TP = Cast<UMaterialExpressionTextureSampleParameter2D>(Expr))
{
bIsParam = true;
ParamObj->SetStringField(TEXT("name"), TP->ParameterName.ToString());
ParamObj->SetStringField(TEXT("type"), TEXT("Texture"));
ParamObj->SetStringField(TEXT("group"), TP->Group.ToString());
if (TP->Texture)
ParamObj->SetStringField(TEXT("defaultValue"), TP->Texture->GetPathName());
}
else if (auto* SSP = Cast<UMaterialExpressionStaticSwitchParameter>(Expr))
{
bIsParam = true;
ParamObj->SetStringField(TEXT("name"), SSP->ParameterName.ToString());
ParamObj->SetStringField(TEXT("type"), TEXT("StaticSwitch"));
ParamObj->SetStringField(TEXT("group"), SSP->Group.ToString());
ParamObj->SetBoolField(TEXT("defaultValue"), SSP->DefaultValue);
}
if (bIsParam)
{
Parameters.Add(MakeShared<FJsonValueObject>(ParamObj));
}
}
Result->SetArrayField(TEXT("parameters"), Parameters);
// Referenced textures
TArray<TSharedPtr<FJsonValue>> ReferencedTextures;
auto RefTexObjs = Material->GetReferencedTextures();
for (const TObjectPtr<UObject>& TexObj : RefTexObjs)
{
if (TexObj)
{
ReferencedTextures.Add(MakeShared<FJsonValueString>(TexObj->GetPathName()));
}
}
Result->SetArrayField(TEXT("referencedTextures"), ReferencedTextures);
// Graph node count
int32 GraphNodeCount = 0;
if (Material->MaterialGraph)
{
GraphNodeCount = Material->MaterialGraph->Nodes.Num();
}
Result->SetNumberField(TEXT("graphNodeCount"), GraphNodeCount);
// Usage flags
TSharedRef<FJsonObject> UsageFlags = MakeShared<FJsonObject>();
UsageFlags->SetBoolField(TEXT("bUsedWithSkeletalMesh"), Material->bUsedWithSkeletalMesh != 0);
UsageFlags->SetBoolField(TEXT("bUsedWithMorphTargets"), Material->bUsedWithMorphTargets != 0);
UsageFlags->SetBoolField(TEXT("bUsedWithNiagaraSprites"), Material->bUsedWithNiagaraSprites != 0);
UsageFlags->SetBoolField(TEXT("bUsedWithParticleSprites"), Material->bUsedWithParticleSprites != 0);
UsageFlags->SetBoolField(TEXT("bUsedWithStaticLighting"), Material->bUsedWithStaticLighting != 0);
Result->SetObjectField(TEXT("usageFlags"), UsageFlags);
// Opacity mask clip value
Result->SetNumberField(TEXT("opacityMaskClipValue"), Material->OpacityMaskClipValue);
// Additional settings
Result->SetBoolField(TEXT("ditheredLODTransition"), Material->DitheredLODTransition != 0);
Result->SetBoolField(TEXT("bAllowNegativeEmissiveColor"), Material->bAllowNegativeEmissiveColor != 0);
// Texture sample count (simple expression scan)
int32 TextureSampleCount = 0;
for (UMaterialExpression* Expr : Expressions)
{
if (Expr && Expr->IsA<UMaterialExpressionTextureSample>())
{
TextureSampleCount++;
}
}
Result->SetNumberField(TEXT("textureSampleCount"), TextureSampleCount);
return JsonToString(Result);
}
// Try loading as MaterialInstance
FString MILoadError;
UMaterialInstanceConstant* MI = LoadMaterialInstanceByName(DecodedName, MILoadError);
if (MI)
{
UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: GetMaterial — loaded material instance '%s'"), *MI->GetName());
TSharedRef<FJsonObject> Result = MakeShared<FJsonObject>();
Result->SetStringField(TEXT("name"), MI->GetName());
Result->SetStringField(TEXT("path"), MI->GetPathName());
Result->SetStringField(TEXT("type"), TEXT("MaterialInstance"));
if (MI->Parent)
{
Result->SetStringField(TEXT("parent"), MI->Parent->GetName());
Result->SetStringField(TEXT("parentPath"), MI->Parent->GetPathName());
}
// Overridden parameters
TArray<TSharedPtr<FJsonValue>> OverriddenParams;
// Scalar parameters
for (const FScalarParameterValue& Param : MI->ScalarParameterValues)
{
TSharedRef<FJsonObject> PObj = MakeShared<FJsonObject>();
PObj->SetStringField(TEXT("name"), Param.ParameterInfo.Name.ToString());
PObj->SetStringField(TEXT("type"), TEXT("Scalar"));
PObj->SetNumberField(TEXT("value"), Param.ParameterValue);
OverriddenParams.Add(MakeShared<FJsonValueObject>(PObj));
}
// Vector parameters
for (const FVectorParameterValue& Param : MI->VectorParameterValues)
{
TSharedRef<FJsonObject> PObj = MakeShared<FJsonObject>();
PObj->SetStringField(TEXT("name"), Param.ParameterInfo.Name.ToString());
PObj->SetStringField(TEXT("type"), TEXT("Vector"));
TSharedRef<FJsonObject> Val = MakeShared<FJsonObject>();
Val->SetNumberField(TEXT("r"), Param.ParameterValue.R);
Val->SetNumberField(TEXT("g"), Param.ParameterValue.G);
Val->SetNumberField(TEXT("b"), Param.ParameterValue.B);
Val->SetNumberField(TEXT("a"), Param.ParameterValue.A);
PObj->SetObjectField(TEXT("value"), Val);
OverriddenParams.Add(MakeShared<FJsonValueObject>(PObj));
}
// Texture parameters
for (const FTextureParameterValue& Param : MI->TextureParameterValues)
{
TSharedRef<FJsonObject> PObj = MakeShared<FJsonObject>();
PObj->SetStringField(TEXT("name"), Param.ParameterInfo.Name.ToString());
PObj->SetStringField(TEXT("type"), TEXT("Texture"));
if (Param.ParameterValue)
PObj->SetStringField(TEXT("value"), Param.ParameterValue->GetPathName());
else
PObj->SetStringField(TEXT("value"), TEXT("None"));
OverriddenParams.Add(MakeShared<FJsonValueObject>(PObj));
}
// Static switch parameters
for (const FStaticSwitchParameter& Param : MI->GetStaticParameters().StaticSwitchParameters)
{
TSharedRef<FJsonObject> PObj = MakeShared<FJsonObject>();
PObj->SetStringField(TEXT("name"), Param.ParameterInfo.Name.ToString());
PObj->SetStringField(TEXT("type"), TEXT("StaticSwitch"));
PObj->SetBoolField(TEXT("value"), Param.Value);
PObj->SetBoolField(TEXT("overridden"), Param.bOverride);
OverriddenParams.Add(MakeShared<FJsonValueObject>(PObj));
}
Result->SetArrayField(TEXT("overriddenParameters"), OverriddenParams);
return JsonToString(Result);
}
return MakeErrorJson(FString::Printf(TEXT("Material or MaterialInstance '%s' not found. Use list_materials to see available assets."), *DecodedName));
}
// ============================================================
// HandleGetMaterialGraph — serialized graph for a material
// ============================================================
FString FBlueprintMCPServer::HandleGetMaterialGraph(const TMap<FString, FString>& Params)
{
const FString* Name = Params.Find(TEXT("name"));
if (!Name || Name->IsEmpty())
{
return MakeErrorJson(TEXT("Missing 'name' parameter"));
}
FString DecodedName = UrlDecode(*Name);
FString LoadError;
UMaterial* Material = LoadMaterialByName(DecodedName, LoadError);
if (!Material)
{
return MakeErrorJson(LoadError);
}
UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: GetMaterialGraph — material '%s'"), *Material->GetName());
// Ensure the material graph is built
if (!Material->MaterialGraph)
{
UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: GetMaterialGraph — MaterialGraph is null, attempting rebuild"));
// The material graph is built lazily by the material editor; force-create it
Material->MaterialGraph = CastChecked<UMaterialGraph>(
FBlueprintEditorUtils::CreateNewGraph(Material, NAME_None, UMaterialGraph::StaticClass(), UMaterialGraphSchema::StaticClass()));
Material->MaterialGraph->Material = Material;
Material->MaterialGraph->RebuildGraph();
}
if (!Material->MaterialGraph)
{
return MakeErrorJson(TEXT("Could not build MaterialGraph for this material"));
}
TSharedPtr<FJsonObject> GraphJson = SerializeGraph(Material->MaterialGraph);
if (!GraphJson.IsValid())
{
return MakeErrorJson(TEXT("Failed to serialize material graph"));
}
// Add material name context
GraphJson->SetStringField(TEXT("material"), Material->GetName());
GraphJson->SetStringField(TEXT("materialPath"), Material->GetPathName());
return JsonToString(GraphJson.ToSharedRef());
}
// ============================================================
// HandleDescribeMaterial — human-readable material description
// ============================================================
FString FBlueprintMCPServer::HandleDescribeMaterial(const FString& Body)
{
TSharedPtr<FJsonObject> Json = ParseBodyJson(Body);
if (!Json.IsValid())
{
return MakeErrorJson(TEXT("Invalid JSON body"));
}
FString MaterialName = Json->GetStringField(TEXT("material"));
if (MaterialName.IsEmpty())
{
return MakeErrorJson(TEXT("Missing required field: material"));
}
FString LoadError;
UMaterial* Material = LoadMaterialByName(MaterialName, LoadError);
if (!Material)
{
return MakeErrorJson(LoadError);
}
UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: DescribeMaterial — '%s'"), *Material->GetName());
// Ensure material graph is built
if (!Material->MaterialGraph)
{
Material->MaterialGraph = CastChecked<UMaterialGraph>(
FBlueprintEditorUtils::CreateNewGraph(Material, NAME_None, UMaterialGraph::StaticClass(), UMaterialGraphSchema::StaticClass()));
Material->MaterialGraph->Material = Material;
Material->MaterialGraph->RebuildGraph();
}
if (!Material->MaterialGraph)
{
return MakeErrorJson(TEXT("Could not build MaterialGraph for this material"));
}
// Recursive helper: trace backwards from a pin and build a description string
TFunction<FString(UEdGraphPin*, int32)> TracePin = [&TracePin](UEdGraphPin* Pin, int32 Depth) -> FString
{
if (!Pin || Depth > 10)
return TEXT("(unknown)");
// If no connections, report as unconnected
if (Pin->LinkedTo.Num() == 0)
{
if (!Pin->DefaultValue.IsEmpty())
return FString::Printf(TEXT("(default: %s)"), *Pin->DefaultValue);
return TEXT("(unconnected)");
}
TArray<FString> Sources;
for (UEdGraphPin* LinkedPin : Pin->LinkedTo)
{
if (!LinkedPin || !LinkedPin->GetOwningNode()) continue;
UEdGraphNode* SourceNode = LinkedPin->GetOwningNode();
FString NodeDesc;
// Check if this is a material graph node
if (UMaterialGraphNode* MatNode = Cast<UMaterialGraphNode>(SourceNode))
{
UMaterialExpression* Expr = MatNode->MaterialExpression;
if (!Expr)
{
NodeDesc = TEXT("(null expression)");
}
else if (auto* SP = Cast<UMaterialExpressionScalarParameter>(Expr))
{
NodeDesc = FString::Printf(TEXT("ScalarParam \"%s\" (default: %.4f)"), *SP->ParameterName.ToString(), SP->DefaultValue);
}
else if (auto* VP = Cast<UMaterialExpressionVectorParameter>(Expr))
{
NodeDesc = FString::Printf(TEXT("VectorParam \"%s\" (default: R=%.2f G=%.2f B=%.2f A=%.2f)"),
*VP->ParameterName.ToString(), VP->DefaultValue.R, VP->DefaultValue.G, VP->DefaultValue.B, VP->DefaultValue.A);
}
else if (auto* TP = Cast<UMaterialExpressionTextureSampleParameter2D>(Expr))
{
FString TexName = TP->Texture ? TP->Texture->GetName() : TEXT("None");
NodeDesc = FString::Printf(TEXT("TextureParam \"%s\" (%s)"), *TP->ParameterName.ToString(), *TexName);
}
else if (auto* SSP = Cast<UMaterialExpressionStaticSwitchParameter>(Expr))
{
NodeDesc = FString::Printf(TEXT("StaticSwitchParam \"%s\" (default: %s)"),
*SSP->ParameterName.ToString(), SSP->DefaultValue ? TEXT("true") : TEXT("false"));
}
else if (auto* SC = Cast<UMaterialExpressionConstant>(Expr))
{
NodeDesc = FString::Printf(TEXT("Constant(%.4f)"), SC->R);
}
else if (auto* C3 = Cast<UMaterialExpressionConstant3Vector>(Expr))
{
NodeDesc = FString::Printf(TEXT("Constant3(R=%.2f G=%.2f B=%.2f)"), C3->Constant.R, C3->Constant.G, C3->Constant.B);
}
else if (auto* C4 = Cast<UMaterialExpressionConstant4Vector>(Expr))
{
NodeDesc = FString::Printf(TEXT("Constant4(R=%.2f G=%.2f B=%.2f A=%.2f)"), C4->Constant.R, C4->Constant.G, C4->Constant.B, C4->Constant.A);
}
else if (auto* TS = Cast<UMaterialExpressionTextureSample>(Expr))
{
FString TexName = TS->Texture ? TS->Texture->GetName() : TEXT("None");
NodeDesc = FString::Printf(TEXT("TextureSample(%s)"), *TexName);
}
else if (auto* MFC = Cast<UMaterialExpressionMaterialFunctionCall>(Expr))
{
FString FuncName = MFC->MaterialFunction ? MFC->MaterialFunction->GetName() : TEXT("None");
NodeDesc = FString::Printf(TEXT("FunctionCall(%s)"), *FuncName);
}
else
{
NodeDesc = Expr->GetClass()->GetName();
}
// If the source node has input pins with connections, recurse
TArray<FString> InputDescs;
for (UEdGraphPin* InputPin : SourceNode->Pins)
{
if (!InputPin || InputPin->Direction != EGPD_Input || InputPin->LinkedTo.Num() == 0) continue;
FString InputDesc = TracePin(InputPin, Depth + 1);
InputDescs.Add(InputDesc);
}
if (InputDescs.Num() > 0)
{
NodeDesc += TEXT(" <- (") + FString::Join(InputDescs, TEXT(", ")) + TEXT(")");
}
}
else
{
// Non-material node (e.g., root, comment), just use title
NodeDesc = SourceNode->GetNodeTitle(ENodeTitleType::FullTitle).ToString();
}
Sources.Add(NodeDesc);
}
if (Sources.Num() == 1)
return Sources[0];
return TEXT("(") + FString::Join(Sources, TEXT(", ")) + TEXT(")");
};
// Find root node and trace each input
TArray<TSharedPtr<FJsonValue>> InputDescriptions;
UMaterialGraphNode_Root* RootNode = nullptr;
for (UEdGraphNode* Node : Material->MaterialGraph->Nodes)
{
RootNode = Cast<UMaterialGraphNode_Root>(Node);
if (RootNode) break;
}
if (!RootNode)
{
return MakeErrorJson(TEXT("Could not find root node in material graph"));
}
for (UEdGraphPin* Pin : RootNode->Pins)
{
if (!Pin || Pin->Direction != EGPD_Input) continue;
FString PinName = Pin->PinName.ToString();
FString Description;
if (Pin->LinkedTo.Num() == 0)
{
Description = TEXT("(unconnected)");
}
else
{
Description = TracePin(Pin, 0);
}
TSharedRef<FJsonObject> InputObj = MakeShared<FJsonObject>();
InputObj->SetStringField(TEXT("input"), PinName);
InputObj->SetStringField(TEXT("chain"), Description);
InputObj->SetBoolField(TEXT("connected"), Pin->LinkedTo.Num() > 0);
InputDescriptions.Add(MakeShared<FJsonValueObject>(InputObj));
}
TSharedRef<FJsonObject> Result = MakeShared<FJsonObject>();
Result->SetBoolField(TEXT("success"), true);
Result->SetStringField(TEXT("material"), Material->GetName());
Result->SetStringField(TEXT("materialPath"), Material->GetPathName());
Result->SetArrayField(TEXT("inputs"), InputDescriptions);
// Also include a compact text description
FString TextDesc;
for (const TSharedPtr<FJsonValue>& Val : InputDescriptions)
{
TSharedPtr<FJsonObject> Obj = Val->AsObject();
if (!Obj.IsValid()) continue;
FString InputName = Obj->GetStringField(TEXT("input"));
FString Chain = Obj->GetStringField(TEXT("chain"));
bool bConnected = Obj->GetBoolField(TEXT("connected"));
if (bConnected)
{
TextDesc += FString::Printf(TEXT("%s <- %s\n"), *InputName, *Chain);
}
}
if (!TextDesc.IsEmpty())
{
Result->SetStringField(TEXT("description"), TextDesc);
}
return JsonToString(Result);
}
// ============================================================
// HandleSearchMaterials — search expressions and parameters
// ============================================================
FString FBlueprintMCPServer::HandleSearchMaterials(const TMap<FString, FString>& Params)
{
const FString* Query = Params.Find(TEXT("query"));
if (!Query || Query->IsEmpty())
{
return MakeErrorJson(TEXT("Missing 'query' parameter"));
}
FString DecodedQuery = UrlDecode(*Query);
int32 MaxResults = 50;
if (const FString* M = Params.Find(TEXT("maxResults")))
{
MaxResults = FMath::Clamp(FCString::Atoi(**M), 1, 200);
}
TArray<TSharedPtr<FJsonValue>> Results;
for (const FAssetData& Asset : AllMaterialAssets)
{
if (Results.Num() >= MaxResults) break;
FString MatName = Asset.AssetName.ToString();
// Check material name first
bool bNameMatch = MatName.Contains(DecodedQuery, ESearchCase::IgnoreCase);
UMaterial* Material = Cast<UMaterial>(const_cast<FAssetData&>(Asset).GetAsset());
if (!Material) continue;
auto Expressions = Material->GetExpressions();
if (bNameMatch)
{
// Add a match for the material itself
TSharedRef<FJsonObject> R = MakeShared<FJsonObject>();
R->SetStringField(TEXT("material"), MatName);
R->SetStringField(TEXT("materialPath"), Asset.PackageName.ToString());
R->SetStringField(TEXT("matchType"), TEXT("materialName"));
Results.Add(MakeShared<FJsonValueObject>(R));
}
// Search expressions
for (UMaterialExpression* Expr : Expressions)
{
if (!Expr || Results.Num() >= MaxResults) continue;
FString ExprDesc = Expr->GetDescription();
FString ExprClass = Expr->GetClass()->GetName();
// Check parameter name
FString ParamName;
if (auto* SP = Cast<UMaterialExpressionScalarParameter>(Expr))
ParamName = SP->ParameterName.ToString();
else if (auto* VP = Cast<UMaterialExpressionVectorParameter>(Expr))
ParamName = VP->ParameterName.ToString();
else if (auto* TP = Cast<UMaterialExpressionTextureSampleParameter2D>(Expr))
ParamName = TP->ParameterName.ToString();
else if (auto* SSP = Cast<UMaterialExpressionStaticSwitchParameter>(Expr))
ParamName = SSP->ParameterName.ToString();
bool bExprMatch = ExprDesc.Contains(DecodedQuery, ESearchCase::IgnoreCase) ||
ExprClass.Contains(DecodedQuery, ESearchCase::IgnoreCase) ||
(!ParamName.IsEmpty() && ParamName.Contains(DecodedQuery, ESearchCase::IgnoreCase));
if (bExprMatch)
{
TSharedRef<FJsonObject> R = MakeShared<FJsonObject>();
R->SetStringField(TEXT("material"), MatName);
R->SetStringField(TEXT("materialPath"), Asset.PackageName.ToString());
R->SetStringField(TEXT("matchType"), TEXT("expression"));
R->SetStringField(TEXT("expressionClass"), ExprClass);
if (!ExprDesc.IsEmpty())
R->SetStringField(TEXT("description"), ExprDesc);
if (!ParamName.IsEmpty())
R->SetStringField(TEXT("parameterName"), ParamName);
Results.Add(MakeShared<FJsonValueObject>(R));
}
}
}
TSharedRef<FJsonObject> Result = MakeShared<FJsonObject>();
Result->SetStringField(TEXT("query"), DecodedQuery);
Result->SetNumberField(TEXT("resultCount"), Results.Num());
Result->SetArrayField(TEXT("results"), Results);
return JsonToString(Result);
}
// ============================================================
// HandleFindMaterialReferences — find assets referencing a material
// ============================================================
FString FBlueprintMCPServer::HandleFindMaterialReferences(const FString& Body)
{
TSharedPtr<FJsonObject> Json = ParseBodyJson(Body);
if (!Json.IsValid())
{
return MakeErrorJson(TEXT("Invalid JSON body"));
}
FString MaterialName = Json->GetStringField(TEXT("material"));
if (MaterialName.IsEmpty())
{
return MakeErrorJson(TEXT("Missing required field: material"));
}
// Try to find the material's package path
FString PackagePath;
FAssetData* MatAsset = FindMaterialAsset(MaterialName);
if (MatAsset)
{
PackagePath = MatAsset->PackageName.ToString();
}
else
{
// Try material instance
FAssetData* MIAsset = FindMaterialInstanceAsset(MaterialName);
if (MIAsset)
{
PackagePath = MIAsset->PackageName.ToString();
}
}
if (PackagePath.IsEmpty())
{
return MakeErrorJson(FString::Printf(TEXT("Material '%s' not found. Use list_materials to see available assets."), *MaterialName));
}
UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: FindMaterialReferences — '%s' (package: %s)"), *MaterialName, *PackagePath);
IAssetRegistry& Registry = *IAssetRegistry::Get();
TArray<FName> Referencers;
Registry.GetReferencers(FName(*PackagePath), Referencers);
TArray<TSharedPtr<FJsonValue>> RefArray;
for (const FName& Ref : Referencers)
{
FString RefStr = Ref.ToString();
// Skip self-reference
if (RefStr == PackagePath) continue;
RefArray.Add(MakeShared<FJsonValueString>(RefStr));
}
TSharedRef<FJsonObject> Result = MakeShared<FJsonObject>();
Result->SetBoolField(TEXT("success"), true);
Result->SetStringField(TEXT("material"), MaterialName);
Result->SetStringField(TEXT("packagePath"), PackagePath);
Result->SetNumberField(TEXT("totalReferencers"), RefArray.Num());
Result->SetArrayField(TEXT("referencers"), RefArray);
return JsonToString(Result);
}
// ============================================================
// HandleListMaterialFunctions — list MaterialFunction assets
// ============================================================
FString FBlueprintMCPServer::HandleListMaterialFunctions(const TMap<FString, FString>& Params)
{
const FString* Filter = Params.Find(TEXT("filter"));
TArray<TSharedPtr<FJsonValue>> Entries;
for (const FAssetData& Asset : AllMaterialFunctionAssets)
{
FString Name = Asset.AssetName.ToString();
FString Path = Asset.PackageName.ToString();
if (Filter && !Filter->IsEmpty())
{
if (!Name.Contains(*Filter, ESearchCase::IgnoreCase) &&
!Path.Contains(*Filter, ESearchCase::IgnoreCase))
{
continue;
}
}
TSharedRef<FJsonObject> Entry = MakeShared<FJsonObject>();
Entry->SetStringField(TEXT("name"), Name);
Entry->SetStringField(TEXT("path"), Path);
Entries.Add(MakeShared<FJsonValueObject>(Entry));
}
TSharedRef<FJsonObject> Result = MakeShared<FJsonObject>();
Result->SetNumberField(TEXT("count"), Entries.Num());
Result->SetNumberField(TEXT("total"), AllMaterialFunctionAssets.Num());
Result->SetArrayField(TEXT("functions"), Entries);
return JsonToString(Result);
}
// ============================================================
// HandleGetMaterialFunction — detailed info about a material function
// ============================================================
FString FBlueprintMCPServer::HandleGetMaterialFunction(const TMap<FString, FString>& Params)
{
const FString* Name = Params.Find(TEXT("name"));
if (!Name || Name->IsEmpty())
{
return MakeErrorJson(TEXT("Missing 'name' parameter"));
}
FString DecodedName = UrlDecode(*Name);
FString LoadError;
UMaterialFunction* MF = LoadMaterialFunctionByName(DecodedName, LoadError);
if (!MF)
{
return MakeErrorJson(LoadError);
}
UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: GetMaterialFunction — '%s'"), *MF->GetName());
TSharedRef<FJsonObject> Result = MakeShared<FJsonObject>();
Result->SetStringField(TEXT("name"), MF->GetName());
Result->SetStringField(TEXT("path"), MF->GetPathName());
Result->SetStringField(TEXT("description"), MF->GetDescription());
// Expression count
auto Expressions = MF->GetExpressions();
Result->SetNumberField(TEXT("expressionCount"), Expressions.Num());
// List function inputs and outputs from expressions
TArray<TSharedPtr<FJsonValue>> Inputs;
TArray<TSharedPtr<FJsonValue>> Outputs;
TArray<TSharedPtr<FJsonValue>> ExpressionList;
{
for (UMaterialExpression* Expr : Expressions)
{
if (!Expr) continue;
if (auto* FI = Cast<UMaterialExpressionFunctionInput>(Expr))
{
TSharedRef<FJsonObject> InputObj = MakeShared<FJsonObject>();
InputObj->SetStringField(TEXT("name"), FI->InputName.ToString());
InputObj->SetStringField(TEXT("type"), TEXT("FunctionInput"));
InputObj->SetNumberField(TEXT("posX"), FI->MaterialExpressionEditorX);
InputObj->SetNumberField(TEXT("posY"), FI->MaterialExpressionEditorY);
Inputs.Add(MakeShared<FJsonValueObject>(InputObj));
}
else if (auto* FO = Cast<UMaterialExpressionFunctionOutput>(Expr))
{
TSharedRef<FJsonObject> OutputObj = MakeShared<FJsonObject>();
OutputObj->SetStringField(TEXT("name"), FO->OutputName.ToString());
OutputObj->SetStringField(TEXT("type"), TEXT("FunctionOutput"));
OutputObj->SetNumberField(TEXT("posX"), FO->MaterialExpressionEditorX);
OutputObj->SetNumberField(TEXT("posY"), FO->MaterialExpressionEditorY);
Outputs.Add(MakeShared<FJsonValueObject>(OutputObj));
}
// Serialize every expression
TSharedPtr<FJsonObject> ExprJson = SerializeMaterialExpression(Expr);
if (ExprJson.IsValid())
{
ExpressionList.Add(MakeShared<FJsonValueObject>(ExprJson.ToSharedRef()));
}
}
}
Result->SetArrayField(TEXT("inputs"), Inputs);
Result->SetArrayField(TEXT("outputs"), Outputs);
Result->SetArrayField(TEXT("expressions"), ExpressionList);
// If the function has an editor graph, serialize it
UEdGraph* FuncGraph = MF->MaterialGraph;
if (FuncGraph)
{
TSharedPtr<FJsonObject> GraphJson = SerializeGraph(FuncGraph);
if (GraphJson.IsValid())
{
Result->SetObjectField(TEXT("graph"), GraphJson);
}
}
return JsonToString(Result);
}
// ============================================================
// HandleValidateMaterial — force recompile and check for errors
// ============================================================
FString FBlueprintMCPServer::HandleValidateMaterial(const FString& Body)
{
TSharedPtr<FJsonObject> Json = ParseBodyJson(Body);
if (!Json.IsValid())
{
return MakeErrorJson(TEXT("Invalid JSON body"));
}
FString MaterialName = Json->GetStringField(TEXT("material"));
if (MaterialName.IsEmpty())
{
return MakeErrorJson(TEXT("Missing required field: material"));
}
// Load material
FString LoadError;
UMaterial* Material = LoadMaterialByName(MaterialName, LoadError);
if (!Material)
{
return MakeErrorJson(LoadError);
}
UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Validating material '%s'"), *Material->GetName());
// Force recompile by triggering PreEditChange/PostEditChange
Material->PreEditChange(nullptr);
Material->PostEditChange();
// Collect compilation errors
TArray<TSharedPtr<FJsonValue>> ErrorArray;
bool bValid = true;
// Check for compilation errors via FMaterialResource on current platform
FMaterialResource* Resource = Material->GetMaterialResource(GMaxRHIFeatureLevel);
if (Resource)
{
const TArray<FString>& CompileErrors = Resource->GetCompileErrors();
for (const FString& Err : CompileErrors)
{
bValid = false;
ErrorArray.Add(MakeShared<FJsonValueString>(Err));
}
}
// Count expressions and connections
auto Expressions = Material->GetExpressions();
int32 ExprCount = Expressions.Num();
int32 ConnectionCount = 0;
if (Material->MaterialGraph)
{
for (UEdGraphNode* Node : Material->MaterialGraph->Nodes)
{
if (!Node) continue;
for (UEdGraphPin* Pin : Node->Pins)
{
if (Pin && Pin->Direction == EGPD_Output)
{
ConnectionCount += Pin->LinkedTo.Num();
}
}
}
}
TSharedRef<FJsonObject> Result = MakeShared<FJsonObject>();
Result->SetBoolField(TEXT("valid"), bValid);
Result->SetStringField(TEXT("material"), Material->GetName());
Result->SetStringField(TEXT("materialPath"), Material->GetPathName());
Result->SetNumberField(TEXT("expressionCount"), ExprCount);
Result->SetNumberField(TEXT("connectionCount"), ConnectionCount);
Result->SetArrayField(TEXT("errors"), ErrorArray);
Result->SetNumberField(TEXT("errorCount"), ErrorArray.Num());
UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Material '%s' validation %s (%d errors)"),
*Material->GetName(), bValid ? TEXT("passed") : TEXT("failed"), ErrorArray.Num());
return JsonToString(Result);
}

View File

@@ -0,0 +1,601 @@
#include "BlueprintMCPServer.h"
#include "Engine/Blueprint.h"
#include "EdGraph/EdGraph.h"
#include "EdGraph/EdGraphPin.h"
#include "K2Node.h"
#include "K2Node_FunctionEntry.h"
#include "K2Node_CustomEvent.h"
#include "K2Node_EditablePinBase.h"
#include "Kismet2/BlueprintEditorUtils.h"
#include "Kismet2/KismetEditorUtilities.h"
#include "Serialization/JsonReader.h"
#include "Serialization/JsonWriter.h"
#include "Serialization/JsonSerializer.h"
#include "UObject/UObjectIterator.h"
FString FBlueprintMCPServer::HandleChangeFunctionParamType(const FString& Body)
{
TSharedPtr<FJsonObject> Json = ParseBodyJson(Body);
if (!Json.IsValid())
{
return MakeErrorJson(TEXT("Invalid JSON body"));
}
FString BlueprintName = Json->GetStringField(TEXT("blueprint"));
FString FunctionName = Json->GetStringField(TEXT("functionName"));
FString ParamName = Json->GetStringField(TEXT("paramName"));
FString NewTypeName = Json->GetStringField(TEXT("newType"));
if (BlueprintName.IsEmpty() || FunctionName.IsEmpty() || ParamName.IsEmpty() || NewTypeName.IsEmpty())
{
return MakeErrorJson(TEXT("Missing required fields: blueprint, functionName, paramName, newType"));
}
// Load Blueprint
FString LoadError;
UBlueprint* BP = LoadBlueprintByName(BlueprintName, LoadError);
if (!BP)
{
return MakeErrorJson(LoadError);
}
// Resolve the new type using the shared resolver (supports primitives, structs, enums, and object references)
FEdGraphPinType NewPinType;
FString TypeError;
if (!ResolveTypeFromString(NewTypeName, NewPinType, TypeError))
{
return MakeErrorJson(TypeError);
}
// Find the entry node: K2Node_FunctionEntry in a function graph,
// or K2Node_CustomEvent in any graph
UK2Node_EditablePinBase* EntryNode = nullptr;
FString FoundNodeType;
TArray<UEdGraph*> AllGraphs;
BP->GetAllGraphs(AllGraphs);
// Strategy 1: Look for a function graph matching the name
for (UEdGraph* Graph : AllGraphs)
{
if (Graph && Graph->GetName().Equals(FunctionName, ESearchCase::IgnoreCase))
{
for (UEdGraphNode* Node : Graph->Nodes)
{
if (UK2Node_FunctionEntry* FuncEntry = Cast<UK2Node_FunctionEntry>(Node))
{
EntryNode = FuncEntry;
FoundNodeType = TEXT("FunctionEntry");
break;
}
}
if (EntryNode) break;
}
}
// Strategy 2: Search for a K2Node_CustomEvent with matching CustomFunctionName
if (!EntryNode)
{
for (UEdGraph* Graph : AllGraphs)
{
if (!Graph) continue;
for (UEdGraphNode* Node : Graph->Nodes)
{
if (UK2Node_CustomEvent* CustomEvent = Cast<UK2Node_CustomEvent>(Node))
{
if (CustomEvent->CustomFunctionName.ToString().Equals(FunctionName, ESearchCase::IgnoreCase))
{
EntryNode = CustomEvent;
FoundNodeType = TEXT("CustomEvent");
break;
}
}
}
if (EntryNode) break;
}
}
if (!EntryNode)
{
// List available functions/events for debugging
TArray<TSharedPtr<FJsonValue>> Available;
for (UEdGraph* Graph : AllGraphs)
{
if (!Graph) continue;
for (UEdGraphNode* Node : Graph->Nodes)
{
if (UK2Node_FunctionEntry* FE = Cast<UK2Node_FunctionEntry>(Node))
{
Available.Add(MakeShared<FJsonValueString>(
FString::Printf(TEXT("function:%s"), *Graph->GetName())));
}
else if (UK2Node_CustomEvent* CE = Cast<UK2Node_CustomEvent>(Node))
{
Available.Add(MakeShared<FJsonValueString>(
FString::Printf(TEXT("event:%s"), *CE->CustomFunctionName.ToString())));
}
}
}
TSharedRef<FJsonObject> E = MakeShared<FJsonObject>();
E->SetStringField(TEXT("error"), FString::Printf(
TEXT("Function or custom event '%s' not found in Blueprint '%s'"),
*FunctionName, *BlueprintName));
E->SetArrayField(TEXT("availableFunctionsAndEvents"), Available);
return JsonToString(E);
}
// Find the UserDefinedPin matching paramName
bool bPinFound = false;
for (TSharedPtr<FUserPinInfo>& PinInfo : EntryNode->UserDefinedPins)
{
if (PinInfo.IsValid() && PinInfo->PinName.ToString().Equals(ParamName, ESearchCase::IgnoreCase))
{
PinInfo->PinType = NewPinType;
bPinFound = true;
break;
}
}
if (!bPinFound)
{
// List available params for debugging
TArray<TSharedPtr<FJsonValue>> ParamNames;
for (const TSharedPtr<FUserPinInfo>& PinInfo : EntryNode->UserDefinedPins)
{
if (PinInfo.IsValid())
{
ParamNames.Add(MakeShared<FJsonValueString>(PinInfo->PinName.ToString()));
}
}
TSharedRef<FJsonObject> E = MakeShared<FJsonObject>();
E->SetStringField(TEXT("error"), FString::Printf(
TEXT("Parameter '%s' not found in %s '%s'"),
*ParamName, *FoundNodeType, *FunctionName));
E->SetArrayField(TEXT("availableParams"), ParamNames);
return JsonToString(E);
}
// Check for dry run
bool bDryRun = false;
if (Json->HasField(TEXT("dryRun")))
{
bDryRun = Json->GetBoolField(TEXT("dryRun"));
}
if (bDryRun)
{
// Analyze what would change: report connected pins that may disconnect
TArray<TSharedPtr<FJsonValue>> AffectedPins;
for (UEdGraphPin* Pin : EntryNode->Pins)
{
if (Pin && Pin->PinName.ToString().Equals(ParamName, ESearchCase::IgnoreCase) && Pin->LinkedTo.Num() > 0)
{
for (UEdGraphPin* Linked : Pin->LinkedTo)
{
if (Linked && Linked->GetOwningNode())
{
TSharedRef<FJsonObject> AffPin = MakeShared<FJsonObject>();
AffPin->SetStringField(TEXT("pinName"), Pin->PinName.ToString());
AffPin->SetStringField(TEXT("connectedToNode"), Linked->GetOwningNode()->NodeGuid.ToString());
AffPin->SetStringField(TEXT("connectedToPin"), Linked->PinName.ToString());
AffPin->SetStringField(TEXT("currentType"), Pin->PinType.PinCategory.ToString());
if (Pin->PinType.PinSubCategoryObject.IsValid())
AffPin->SetStringField(TEXT("currentSubtype"), Pin->PinType.PinSubCategoryObject->GetName());
AffectedPins.Add(MakeShared<FJsonValueObject>(AffPin));
}
}
}
}
TSharedRef<FJsonObject> Result = MakeShared<FJsonObject>();
Result->SetBoolField(TEXT("dryRun"), true);
Result->SetStringField(TEXT("blueprint"), BlueprintName);
Result->SetStringField(TEXT("functionName"), FunctionName);
Result->SetStringField(TEXT("paramName"), ParamName);
Result->SetStringField(TEXT("newType"), NewTypeName);
Result->SetStringField(TEXT("nodeType"), FoundNodeType);
Result->SetStringField(TEXT("nodeId"), EntryNode->NodeGuid.ToString());
Result->SetNumberField(TEXT("connectionsAtRisk"), AffectedPins.Num());
Result->SetArrayField(TEXT("affectedPins"), AffectedPins);
return JsonToString(Result);
}
UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Changing param '%s' in %s '%s' of '%s' to %s"),
*ParamName, *FoundNodeType, *FunctionName, *BlueprintName, *NewTypeName);
// Reconstruct the node to update output pins with the new type (use schema for MinimalAPI compat)
if (UEdGraph* OwningGraph = EntryNode->GetGraph())
{
if (const UEdGraphSchema* Schema = OwningGraph->GetSchema())
{
Schema->ReconstructNode(*EntryNode);
}
}
// Save
bool bSaved = SaveBlueprintPackage(BP);
UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Parameter type changed, save %s"),
bSaved ? TEXT("succeeded") : TEXT("failed"));
// Serialize the updated entry node state
TSharedPtr<FJsonObject> UpdatedNodeState = SerializeNode(EntryNode);
TSharedRef<FJsonObject> Result = MakeShared<FJsonObject>();
Result->SetBoolField(TEXT("success"), true);
Result->SetStringField(TEXT("blueprint"), BlueprintName);
Result->SetStringField(TEXT("functionName"), FunctionName);
Result->SetStringField(TEXT("paramName"), ParamName);
Result->SetStringField(TEXT("newType"), NewTypeName);
Result->SetStringField(TEXT("nodeType"), FoundNodeType);
Result->SetStringField(TEXT("nodeId"), EntryNode->NodeGuid.ToString());
Result->SetBoolField(TEXT("saved"), bSaved);
if (UpdatedNodeState.IsValid())
{
Result->SetObjectField(TEXT("updatedNode"), UpdatedNodeState);
}
return JsonToString(Result);
}
// ============================================================
// HandleRemoveFunctionParameter
// ============================================================
FString FBlueprintMCPServer::HandleRemoveFunctionParameter(const FString& Body)
{
TSharedPtr<FJsonObject> Json = ParseBodyJson(Body);
if (!Json.IsValid())
{
return MakeErrorJson(TEXT("Invalid JSON body"));
}
FString BlueprintName = Json->GetStringField(TEXT("blueprint"));
FString FunctionName = Json->GetStringField(TEXT("functionName"));
FString ParamName = Json->GetStringField(TEXT("paramName"));
if (BlueprintName.IsEmpty() || FunctionName.IsEmpty() || ParamName.IsEmpty())
{
return MakeErrorJson(TEXT("Missing required fields: blueprint, functionName, paramName"));
}
// Load Blueprint
FString LoadError;
UBlueprint* BP = LoadBlueprintByName(BlueprintName, LoadError);
if (!BP)
{
return MakeErrorJson(LoadError);
}
// Find the entry node
UK2Node_EditablePinBase* EntryNode = nullptr;
FString FoundNodeType;
TArray<UEdGraph*> AllGraphs;
BP->GetAllGraphs(AllGraphs);
// Strategy 1: Look for a function graph matching the name
for (UEdGraph* Graph : AllGraphs)
{
if (Graph && Graph->GetName().Equals(FunctionName, ESearchCase::IgnoreCase))
{
for (UEdGraphNode* Node : Graph->Nodes)
{
if (UK2Node_FunctionEntry* FuncEntry = Cast<UK2Node_FunctionEntry>(Node))
{
EntryNode = FuncEntry;
FoundNodeType = TEXT("FunctionEntry");
break;
}
}
if (EntryNode) break;
}
}
// Strategy 2: Search for a K2Node_CustomEvent with matching CustomFunctionName
if (!EntryNode)
{
for (UEdGraph* Graph : AllGraphs)
{
if (!Graph) continue;
for (UEdGraphNode* Node : Graph->Nodes)
{
if (UK2Node_CustomEvent* CustomEvent = Cast<UK2Node_CustomEvent>(Node))
{
if (CustomEvent->CustomFunctionName.ToString().Equals(FunctionName, ESearchCase::IgnoreCase))
{
EntryNode = CustomEvent;
FoundNodeType = TEXT("CustomEvent");
break;
}
}
}
if (EntryNode) break;
}
}
if (!EntryNode)
{
// List available functions/events for debugging
TArray<TSharedPtr<FJsonValue>> Available;
for (UEdGraph* Graph : AllGraphs)
{
if (!Graph) continue;
for (UEdGraphNode* Node : Graph->Nodes)
{
if (UK2Node_FunctionEntry* FE = Cast<UK2Node_FunctionEntry>(Node))
{
Available.Add(MakeShared<FJsonValueString>(
FString::Printf(TEXT("function:%s"), *Graph->GetName())));
}
else if (UK2Node_CustomEvent* CE = Cast<UK2Node_CustomEvent>(Node))
{
Available.Add(MakeShared<FJsonValueString>(
FString::Printf(TEXT("event:%s"), *CE->CustomFunctionName.ToString())));
}
}
}
TSharedRef<FJsonObject> E = MakeShared<FJsonObject>();
E->SetStringField(TEXT("error"), FString::Printf(
TEXT("Function or custom event '%s' not found in Blueprint '%s'"),
*FunctionName, *BlueprintName));
E->SetArrayField(TEXT("availableFunctionsAndEvents"), Available);
return JsonToString(E);
}
// Find and remove the UserDefinedPin matching paramName
int32 RemovedIndex = INDEX_NONE;
for (int32 i = 0; i < EntryNode->UserDefinedPins.Num(); ++i)
{
if (EntryNode->UserDefinedPins[i].IsValid() &&
EntryNode->UserDefinedPins[i]->PinName.ToString().Equals(ParamName, ESearchCase::IgnoreCase))
{
RemovedIndex = i;
break;
}
}
if (RemovedIndex == INDEX_NONE)
{
// List available params for debugging
TArray<TSharedPtr<FJsonValue>> ParamNames;
for (const TSharedPtr<FUserPinInfo>& PinInfo : EntryNode->UserDefinedPins)
{
if (PinInfo.IsValid())
{
ParamNames.Add(MakeShared<FJsonValueString>(PinInfo->PinName.ToString()));
}
}
TSharedRef<FJsonObject> E = MakeShared<FJsonObject>();
E->SetStringField(TEXT("error"), FString::Printf(
TEXT("Parameter '%s' not found in %s '%s'"),
*ParamName, *FoundNodeType, *FunctionName));
E->SetArrayField(TEXT("availableParams"), ParamNames);
return JsonToString(E);
}
UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Removing param '%s' from %s '%s' in '%s'"),
*ParamName, *FoundNodeType, *FunctionName, *BlueprintName);
// Remove the pin
EntryNode->UserDefinedPins.RemoveAt(RemovedIndex);
// Reconstruct the node to update output pins (use schema for MinimalAPI compat)
if (UEdGraph* OwningGraph = EntryNode->GetGraph())
{
if (const UEdGraphSchema* Schema = OwningGraph->GetSchema())
{
Schema->ReconstructNode(*EntryNode);
}
}
// Save
bool bSaved = SaveBlueprintPackage(BP);
UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Parameter removed, save %s"),
bSaved ? TEXT("succeeded") : TEXT("failed"));
TSharedRef<FJsonObject> Result = MakeShared<FJsonObject>();
Result->SetBoolField(TEXT("success"), true);
Result->SetStringField(TEXT("blueprint"), BlueprintName);
Result->SetStringField(TEXT("functionName"), FunctionName);
Result->SetStringField(TEXT("paramName"), ParamName);
Result->SetStringField(TEXT("nodeType"), FoundNodeType);
Result->SetStringField(TEXT("nodeId"), EntryNode->NodeGuid.ToString());
Result->SetBoolField(TEXT("saved"), bSaved);
return JsonToString(Result);
}
// ============================================================
// HandleAddFunctionParameter
// ============================================================
FString FBlueprintMCPServer::HandleAddFunctionParameter(const FString& Body)
{
TSharedPtr<FJsonObject> Json = ParseBodyJson(Body);
if (!Json.IsValid())
{
return MakeErrorJson(TEXT("Invalid JSON body"));
}
FString BlueprintName = Json->GetStringField(TEXT("blueprint"));
FString FunctionName = Json->GetStringField(TEXT("functionName"));
FString ParamName = Json->GetStringField(TEXT("paramName"));
FString ParamType = Json->GetStringField(TEXT("paramType"));
if (BlueprintName.IsEmpty() || FunctionName.IsEmpty() || ParamName.IsEmpty() || ParamType.IsEmpty())
{
return MakeErrorJson(TEXT("Missing required fields: blueprint, functionName, paramName, paramType"));
}
// Load Blueprint
FString LoadError;
UBlueprint* BP = LoadBlueprintByName(BlueprintName, LoadError);
if (!BP)
{
return MakeErrorJson(LoadError);
}
// Resolve param type
FEdGraphPinType PinType;
FString TypeError;
if (!ResolveTypeFromString(ParamType, PinType, TypeError))
{
return MakeErrorJson(TypeError);
}
// Find the entry node using 3 strategies
UK2Node_EditablePinBase* EntryNode = nullptr;
FString NodeType;
FName FuncFName(*FunctionName);
// Strategy 1: K2Node_FunctionEntry in function graphs
TArray<UEdGraph*> AllGraphs;
BP->GetAllGraphs(AllGraphs);
for (UEdGraph* Graph : AllGraphs)
{
if (!Graph || !Graph->GetName().Equals(FunctionName, ESearchCase::IgnoreCase))
{
continue;
}
// Skip delegate signature graphs (handled in Strategy 3)
if (BP->DelegateSignatureGraphs.Contains(Graph))
{
continue;
}
for (UEdGraphNode* Node : Graph->Nodes)
{
if (UK2Node_FunctionEntry* FE = Cast<UK2Node_FunctionEntry>(Node))
{
EntryNode = FE;
NodeType = TEXT("FunctionEntry");
break;
}
}
if (EntryNode) break;
}
// Strategy 2: K2Node_CustomEvent with matching CustomFunctionName
if (!EntryNode)
{
for (UEdGraph* Graph : AllGraphs)
{
if (!Graph) continue;
for (UEdGraphNode* Node : Graph->Nodes)
{
if (UK2Node_CustomEvent* CE = Cast<UK2Node_CustomEvent>(Node))
{
if (CE->CustomFunctionName.ToString().Equals(FunctionName, ESearchCase::IgnoreCase))
{
EntryNode = CE;
NodeType = TEXT("CustomEvent");
break;
}
}
}
if (EntryNode) break;
}
}
// Strategy 3: K2Node_FunctionEntry in DelegateSignatureGraphs
if (!EntryNode)
{
for (UEdGraph* SigGraph : BP->DelegateSignatureGraphs)
{
if (!SigGraph || !SigGraph->GetName().Equals(FunctionName, ESearchCase::IgnoreCase))
{
continue;
}
for (UEdGraphNode* Node : SigGraph->Nodes)
{
if (UK2Node_FunctionEntry* FE = Cast<UK2Node_FunctionEntry>(Node))
{
EntryNode = FE;
NodeType = TEXT("EventDispatcher");
break;
}
}
if (EntryNode) break;
}
}
if (!EntryNode)
{
// Build a helpful error listing available functions, events, and dispatchers
TArray<TSharedPtr<FJsonValue>> AvailFuncs;
for (UEdGraph* Graph : BP->FunctionGraphs)
{
if (Graph) AvailFuncs.Add(MakeShared<FJsonValueString>(Graph->GetName()));
}
// Custom events
for (UEdGraph* Graph : AllGraphs)
{
if (!Graph) continue;
for (UEdGraphNode* Node : Graph->Nodes)
{
if (UK2Node_CustomEvent* CE = Cast<UK2Node_CustomEvent>(Node))
{
AvailFuncs.Add(MakeShared<FJsonValueString>(
FString::Printf(TEXT("%s (custom event)"), *CE->CustomFunctionName.ToString())));
}
}
}
// Dispatchers
TSet<FName> DelegateNames;
FBlueprintEditorUtils::GetDelegateNameList(BP, DelegateNames);
for (const FName& DN : DelegateNames)
{
AvailFuncs.Add(MakeShared<FJsonValueString>(
FString::Printf(TEXT("%s (event dispatcher)"), *DN.ToString())));
}
TSharedRef<FJsonObject> ErrorResult = MakeShared<FJsonObject>();
ErrorResult->SetStringField(TEXT("error"), FString::Printf(
TEXT("Function, custom event, or event dispatcher '%s' not found in Blueprint '%s'"),
*FunctionName, *BlueprintName));
ErrorResult->SetArrayField(TEXT("availableFunctions"), AvailFuncs);
return JsonToString(ErrorResult);
}
// Check for duplicate parameter name
for (const TSharedPtr<FUserPinInfo>& Existing : EntryNode->UserDefinedPins)
{
if (Existing.IsValid() && Existing->PinName.ToString().Equals(ParamName, ESearchCase::IgnoreCase))
{
return MakeErrorJson(FString::Printf(
TEXT("Parameter '%s' already exists on '%s'"), *ParamName, *FunctionName));
}
}
UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Adding parameter '%s' (type=%s) to %s '%s' in Blueprint '%s'"),
*ParamName, *ParamType, *NodeType, *FunctionName, *BlueprintName);
// Add the parameter pin (EGPD_Output on entry = input to callers)
EntryNode->CreateUserDefinedPin(FName(*ParamName), PinType, EGPD_Output);
FBlueprintEditorUtils::MarkBlueprintAsStructurallyModified(BP);
bool bSaved = SaveBlueprintPackage(BP);
UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Added parameter '%s' to '%s' in '%s' (saved: %s)"),
*ParamName, *FunctionName, *BlueprintName, bSaved ? TEXT("true") : TEXT("false"));
TSharedRef<FJsonObject> Result = MakeShared<FJsonObject>();
Result->SetBoolField(TEXT("success"), true);
Result->SetStringField(TEXT("blueprint"), BlueprintName);
Result->SetStringField(TEXT("functionName"), FunctionName);
Result->SetStringField(TEXT("paramName"), ParamName);
Result->SetStringField(TEXT("paramType"), ParamType);
Result->SetStringField(TEXT("nodeType"), NodeType);
Result->SetBoolField(TEXT("saved"), bSaved);
return JsonToString(Result);
}

View File

@@ -0,0 +1,644 @@
#include "BlueprintMCPServer.h"
#include "Engine/Blueprint.h"
#include "Engine/World.h"
#include "Engine/Level.h"
#include "Engine/LevelScriptBlueprint.h"
#include "EdGraph/EdGraph.h"
#include "EdGraph/EdGraphNode.h"
#include "K2Node_CallFunction.h"
#include "K2Node_Event.h"
#include "K2Node_CustomEvent.h"
#include "K2Node_VariableGet.h"
#include "K2Node_VariableSet.h"
#include "K2Node_BreakStruct.h"
#include "K2Node_MakeStruct.h"
#include "K2Node_FunctionEntry.h"
#include "K2Node_EditablePinBase.h"
#include "AssetRegistry/AssetRegistryModule.h"
#include "AssetRegistry/IAssetRegistry.h"
#include "Serialization/JsonWriter.h"
#include "Serialization/JsonSerializer.h"
#include "UObject/SavePackage.h"
// ============================================================
// Request handlers
// ============================================================
FString FBlueprintMCPServer::HandleList(const TMap<FString, FString>& Params)
{
const FString* Filter = Params.Find(TEXT("filter"));
const FString* ParentClassFilter = Params.Find(TEXT("parentClass"));
const FString* TypeFilter = Params.Find(TEXT("type"));
// type: "all" (default), "regular", "level"
bool bIncludeRegular = !TypeFilter || TypeFilter->IsEmpty() || *TypeFilter == TEXT("all") || *TypeFilter == TEXT("regular");
bool bIncludeLevel = !TypeFilter || TypeFilter->IsEmpty() || *TypeFilter == TEXT("all") || *TypeFilter == TEXT("level");
TArray<TSharedPtr<FJsonValue>> Entries;
if (bIncludeRegular)
for (const FAssetData& Asset : AllBlueprintAssets)
{
FString Name = Asset.AssetName.ToString();
FString Path = Asset.PackageName.ToString();
if (Filter && !Filter->IsEmpty())
{
if (!Name.Contains(*Filter, ESearchCase::IgnoreCase) &&
!Path.Contains(*Filter, ESearchCase::IgnoreCase))
{
continue;
}
}
FString ParentClass;
Asset.GetTagValue(FName(TEXT("ParentClass")), ParentClass);
// Tag stores full path — extract short name
int32 DotIndex;
if (ParentClass.FindLastChar('.', DotIndex))
{
ParentClass = ParentClass.Mid(DotIndex + 1);
}
if (ParentClassFilter && !ParentClassFilter->IsEmpty())
{
if (!ParentClass.Contains(*ParentClassFilter, ESearchCase::IgnoreCase))
{
continue;
}
}
TSharedRef<FJsonObject> Entry = MakeShared<FJsonObject>();
Entry->SetStringField(TEXT("name"), Name);
Entry->SetStringField(TEXT("path"), Path);
Entry->SetStringField(TEXT("parentClass"), ParentClass);
Entries.Add(MakeShared<FJsonValueObject>(Entry));
}
// Also include level blueprints from maps
if (bIncludeLevel)
for (const FAssetData& Asset : AllMapAssets)
{
FString Name = Asset.AssetName.ToString();
FString Path = Asset.PackageName.ToString();
if (Filter && !Filter->IsEmpty())
{
if (!Name.Contains(*Filter, ESearchCase::IgnoreCase) &&
!Path.Contains(*Filter, ESearchCase::IgnoreCase))
{
continue;
}
}
// No parent class filter for level blueprints
if (ParentClassFilter && !ParentClassFilter->IsEmpty())
{
if (!FString(TEXT("LevelScriptActor")).Contains(*ParentClassFilter, ESearchCase::IgnoreCase))
{
continue;
}
}
TSharedRef<FJsonObject> Entry = MakeShared<FJsonObject>();
Entry->SetStringField(TEXT("name"), Name);
Entry->SetStringField(TEXT("path"), Path);
Entry->SetStringField(TEXT("parentClass"), TEXT("LevelScriptActor"));
Entry->SetBoolField(TEXT("isLevelBlueprint"), true);
Entries.Add(MakeShared<FJsonValueObject>(Entry));
}
TSharedRef<FJsonObject> Result = MakeShared<FJsonObject>();
Result->SetNumberField(TEXT("count"), Entries.Num());
Result->SetNumberField(TEXT("total"), AllBlueprintAssets.Num() + AllMapAssets.Num());
Result->SetArrayField(TEXT("blueprints"), Entries);
return JsonToString(Result);
}
FString FBlueprintMCPServer::HandleGetBlueprint(const TMap<FString, FString>& Params)
{
const FString* Name = Params.Find(TEXT("name"));
if (!Name || Name->IsEmpty())
{
return MakeErrorJson(TEXT("Missing 'name' parameter"));
}
FString LoadError;
UBlueprint* BP = LoadBlueprintByName(*Name, LoadError);
if (!BP)
{
return MakeErrorJson(LoadError);
}
return JsonToString(SerializeBlueprint(BP));
}
FString FBlueprintMCPServer::HandleGetGraph(const TMap<FString, FString>& Params)
{
const FString* Name = Params.Find(TEXT("name"));
const FString* GraphName = Params.Find(TEXT("graph"));
if (!Name || Name->IsEmpty() || !GraphName || GraphName->IsEmpty())
{
return MakeErrorJson(TEXT("Missing 'name' or 'graph' parameter"));
}
// URL-decode graph name to handle spaces encoded as '+' or '%20'
FString DecodedGraphName = UrlDecode(*GraphName);
FString LoadError;
UBlueprint* BP = LoadBlueprintByName(*Name, LoadError);
if (!BP)
{
return MakeErrorJson(LoadError);
}
TArray<UEdGraph*> AllGraphs;
BP->GetAllGraphs(AllGraphs);
for (UEdGraph* Graph : AllGraphs)
{
if (Graph && Graph->GetName().Equals(DecodedGraphName, ESearchCase::IgnoreCase))
{
TSharedPtr<FJsonObject> GraphJson = SerializeGraph(Graph);
if (GraphJson.IsValid())
{
return JsonToString(GraphJson.ToSharedRef());
}
}
}
// Not found — list available graphs
TArray<TSharedPtr<FJsonValue>> GraphNames;
for (UEdGraph* Graph : AllGraphs)
{
if (Graph)
{
GraphNames.Add(MakeShared<FJsonValueString>(Graph->GetName()));
}
}
TSharedRef<FJsonObject> E = MakeShared<FJsonObject>();
E->SetStringField(TEXT("error"), FString::Printf(TEXT("Graph '%s' not found"), *DecodedGraphName));
E->SetArrayField(TEXT("availableGraphs"), GraphNames);
return JsonToString(E);
}
FString FBlueprintMCPServer::HandleSearch(const TMap<FString, FString>& Params)
{
const FString* Query = Params.Find(TEXT("query"));
if (!Query || Query->IsEmpty())
{
TSharedRef<FJsonObject> E = MakeShared<FJsonObject>();
E->SetStringField(TEXT("error"), TEXT("Missing 'query' parameter"));
return JsonToString(E);
}
const FString* PathFilter = Params.Find(TEXT("path"));
int32 MaxResults = 50;
if (const FString* M = Params.Find(TEXT("maxResults")))
{
MaxResults = FMath::Clamp(FCString::Atoi(**M), 1, 200);
}
// Build a combined list of all searchable blueprints (regular + level)
auto SearchBlueprint = [&](const FString& AssetName, const FString& Path, UBlueprint* BP, TArray<TSharedPtr<FJsonValue>>& OutResults)
{
TArray<UEdGraph*> Graphs;
BP->GetAllGraphs(Graphs);
for (UEdGraph* Graph : Graphs)
{
if (!Graph || OutResults.Num() >= MaxResults) break;
for (UEdGraphNode* Node : Graph->Nodes)
{
if (!Node || OutResults.Num() >= MaxResults) break;
FString Title = Node->GetNodeTitle(ENodeTitleType::FullTitle).ToString();
FString FuncName, EventName, VarName;
if (auto* CF = Cast<UK2Node_CallFunction>(Node))
{
FuncName = CF->FunctionReference.GetMemberName().ToString();
}
else if (auto* Ev = Cast<UK2Node_Event>(Node))
{
EventName = Ev->EventReference.GetMemberName().ToString();
}
else if (auto* CE = Cast<UK2Node_CustomEvent>(Node))
{
EventName = CE->CustomFunctionName.ToString();
}
else if (auto* VG = Cast<UK2Node_VariableGet>(Node))
{
VarName = VG->GetVarName().ToString();
}
else if (auto* VS = Cast<UK2Node_VariableSet>(Node))
{
VarName = VS->GetVarName().ToString();
}
bool bMatch = Title.Contains(*Query, ESearchCase::IgnoreCase) ||
(!FuncName.IsEmpty() && FuncName.Contains(*Query, ESearchCase::IgnoreCase)) ||
(!EventName.IsEmpty() && EventName.Contains(*Query, ESearchCase::IgnoreCase)) ||
(!VarName.IsEmpty() && VarName.Contains(*Query, ESearchCase::IgnoreCase));
if (bMatch)
{
TSharedRef<FJsonObject> R = MakeShared<FJsonObject>();
R->SetStringField(TEXT("blueprint"), AssetName);
R->SetStringField(TEXT("blueprintPath"), Path);
R->SetStringField(TEXT("graph"), Graph->GetName());
R->SetStringField(TEXT("nodeTitle"), Title);
R->SetStringField(TEXT("nodeClass"), Node->GetClass()->GetName());
if (!FuncName.IsEmpty()) R->SetStringField(TEXT("functionName"), FuncName);
if (!EventName.IsEmpty()) R->SetStringField(TEXT("eventName"), EventName);
if (!VarName.IsEmpty()) R->SetStringField(TEXT("variableName"), VarName);
OutResults.Add(MakeShared<FJsonValueObject>(R));
}
}
}
};
TArray<TSharedPtr<FJsonValue>> Results;
for (const FAssetData& Asset : AllBlueprintAssets)
{
if (Results.Num() >= MaxResults) break;
FString Path = Asset.PackageName.ToString();
if (PathFilter && !PathFilter->IsEmpty() && !Path.Contains(*PathFilter, ESearchCase::IgnoreCase))
{
continue;
}
UBlueprint* BP = Cast<UBlueprint>(const_cast<FAssetData&>(Asset).GetAsset());
if (!BP) continue;
SearchBlueprint(Asset.AssetName.ToString(), Path, BP, Results);
}
// Also search level blueprints
for (FAssetData& MapAsset : AllMapAssets)
{
if (Results.Num() >= MaxResults) break;
FString Path = MapAsset.PackageName.ToString();
if (PathFilter && !PathFilter->IsEmpty() && !Path.Contains(*PathFilter, ESearchCase::IgnoreCase))
{
continue;
}
UWorld* World = Cast<UWorld>(MapAsset.GetAsset());
if (!World || !World->PersistentLevel) continue;
ULevelScriptBlueprint* LevelBP = World->PersistentLevel->GetLevelScriptBlueprint(false);
if (!LevelBP) continue;
int32 BeforeCount = Results.Num();
SearchBlueprint(MapAsset.AssetName.ToString(), Path, LevelBP, Results);
// Tag newly-added entries as level blueprint results
for (int32 i = BeforeCount; i < Results.Num(); ++i)
{
Results[i]->AsObject()->SetBoolField(TEXT("isLevelBlueprint"), true);
}
}
TSharedRef<FJsonObject> Result = MakeShared<FJsonObject>();
Result->SetStringField(TEXT("query"), *Query);
Result->SetNumberField(TEXT("resultCount"), Results.Num());
Result->SetArrayField(TEXT("results"), Results);
return JsonToString(Result);
}
// ============================================================
// HandleTestSave — load a Blueprint and save it unmodified (diagnostic)
// ============================================================
FString FBlueprintMCPServer::HandleTestSave(const TMap<FString, FString>& Params)
{
const FString* Name = Params.Find(TEXT("name"));
if (!Name || Name->IsEmpty())
{
return MakeErrorJson(TEXT("Missing 'name' query parameter"));
}
UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: test-save requested for '%s'"), **Name);
FString LoadError;
UBlueprint* BP = LoadBlueprintByName(*Name, LoadError);
if (!BP)
{
return MakeErrorJson(LoadError);
}
UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: test-save — loaded '%s', GeneratedClass=%s"),
*BP->GetName(),
BP->GeneratedClass ? *BP->GeneratedClass->GetName() : TEXT("null"));
// Attempt save with NO modifications
bool bSaved = SaveBlueprintPackage(BP);
TSharedRef<FJsonObject> Result = MakeShared<FJsonObject>();
Result->SetStringField(TEXT("blueprint"), *Name);
Result->SetStringField(TEXT("packagePath"), BP->GetPackage()->GetName());
Result->SetBoolField(TEXT("hasGeneratedClass"), BP->GeneratedClass != nullptr);
Result->SetBoolField(TEXT("saved"), bSaved);
return JsonToString(Result);
}
// ============================================================
// HandleFindReferences — find all Blueprints referencing an asset
// ============================================================
FString FBlueprintMCPServer::HandleFindReferences(const TMap<FString, FString>& Params)
{
const FString* AssetPath = Params.Find(TEXT("assetPath"));
if (!AssetPath || AssetPath->IsEmpty())
{
return MakeErrorJson(TEXT("Missing 'assetPath' query parameter"));
}
IAssetRegistry& Registry = *IAssetRegistry::Get();
TArray<FName> Referencers;
Registry.GetReferencers(FName(**AssetPath), Referencers);
// Build set of known Blueprint package names for filtering
TSet<FString> BlueprintPackages;
for (const FAssetData& Asset : AllBlueprintAssets)
{
BlueprintPackages.Add(Asset.PackageName.ToString());
}
TArray<TSharedPtr<FJsonValue>> BPRefs;
TArray<TSharedPtr<FJsonValue>> OtherRefs;
for (const FName& Ref : Referencers)
{
FString RefStr = Ref.ToString();
if (BlueprintPackages.Contains(RefStr))
{
BPRefs.Add(MakeShared<FJsonValueString>(RefStr));
}
else
{
OtherRefs.Add(MakeShared<FJsonValueString>(RefStr));
}
}
TSharedRef<FJsonObject> Result = MakeShared<FJsonObject>();
Result->SetStringField(TEXT("assetPath"), **AssetPath);
Result->SetNumberField(TEXT("totalReferencers"), Referencers.Num());
Result->SetNumberField(TEXT("blueprintReferencerCount"), BPRefs.Num());
Result->SetArrayField(TEXT("blueprintReferencers"), BPRefs);
Result->SetNumberField(TEXT("otherReferencerCount"), OtherRefs.Num());
Result->SetArrayField(TEXT("otherReferencers"), OtherRefs);
return JsonToString(Result);
}
// ============================================================
// HandleSearchByType — find all usages of a type across blueprints
// ============================================================
FString FBlueprintMCPServer::HandleSearchByType(const TMap<FString, FString>& Params)
{
const FString* TypeNamePtr = Params.Find(TEXT("typeName"));
if (!TypeNamePtr || TypeNamePtr->IsEmpty())
{
return MakeErrorJson(TEXT("Missing 'typeName' query parameter"));
}
FString TypeName = UrlDecode(*TypeNamePtr);
const FString* Filter = Params.Find(TEXT("filter"));
FString FilterStr = Filter ? UrlDecode(*Filter) : FString();
int32 MaxResults = 200;
if (const FString* M = Params.Find(TEXT("maxResults")))
{
MaxResults = FMath::Clamp(FCString::Atoi(**M), 1, 500);
}
// Strip F/E/U prefix for comparison
FString TypeNameNoPrefix = TypeName;
if (TypeNameNoPrefix.StartsWith(TEXT("F")) || TypeNameNoPrefix.StartsWith(TEXT("E")) || TypeNameNoPrefix.StartsWith(TEXT("U")))
{
TypeNameNoPrefix = TypeNameNoPrefix.Mid(1);
}
auto MatchesType = [&TypeName, &TypeNameNoPrefix](const FString& TestType) -> bool
{
return TestType.Equals(TypeName, ESearchCase::IgnoreCase) ||
TestType.Equals(TypeNameNoPrefix, ESearchCase::IgnoreCase);
};
TArray<TSharedPtr<FJsonValue>> Results;
// Lambda that searches a single Blueprint for type usages
auto SearchOneBlueprint = [&](const FString& BPName, const FString& Path, UBlueprint* BP, bool bIsLevel)
{
// Check variables
for (const FBPVariableDescription& Var : BP->NewVariables)
{
if (Results.Num() >= MaxResults) break;
FString VarSubtype;
if (Var.VarType.PinSubCategoryObject.IsValid())
{
VarSubtype = Var.VarType.PinSubCategoryObject->GetName();
}
if (MatchesType(VarSubtype) || MatchesType(Var.VarType.PinCategory.ToString()))
{
TSharedRef<FJsonObject> R = MakeShared<FJsonObject>();
R->SetStringField(TEXT("blueprint"), BPName);
R->SetStringField(TEXT("blueprintPath"), Path);
R->SetStringField(TEXT("usage"), TEXT("variable"));
R->SetStringField(TEXT("location"), Var.VarName.ToString());
R->SetStringField(TEXT("currentType"), Var.VarType.PinCategory.ToString());
if (!VarSubtype.IsEmpty())
R->SetStringField(TEXT("currentSubtype"), VarSubtype);
if (bIsLevel)
R->SetBoolField(TEXT("isLevelBlueprint"), true);
Results.Add(MakeShared<FJsonValueObject>(R));
}
}
// Check graphs for function/event params, struct nodes, and pin connections
TArray<UEdGraph*> AllGraphs;
BP->GetAllGraphs(AllGraphs);
for (UEdGraph* Graph : AllGraphs)
{
if (!Graph || Results.Num() >= MaxResults) break;
for (UEdGraphNode* Node : Graph->Nodes)
{
if (!Node || Results.Num() >= MaxResults) break;
// Check FunctionEntry/CustomEvent parameters
if (auto* FuncEntry = Cast<UK2Node_FunctionEntry>(Node))
{
for (const TSharedPtr<FUserPinInfo>& PinInfo : FuncEntry->UserDefinedPins)
{
if (!PinInfo.IsValid()) continue;
FString ParamSubtype;
if (PinInfo->PinType.PinSubCategoryObject.IsValid())
ParamSubtype = PinInfo->PinType.PinSubCategoryObject->GetName();
if (MatchesType(ParamSubtype) || MatchesType(PinInfo->PinType.PinCategory.ToString()))
{
TSharedRef<FJsonObject> R = MakeShared<FJsonObject>();
R->SetStringField(TEXT("blueprint"), BPName);
R->SetStringField(TEXT("blueprintPath"), Path);
R->SetStringField(TEXT("usage"), TEXT("functionParameter"));
R->SetStringField(TEXT("location"), FString::Printf(TEXT("%s.%s"),
*Graph->GetName(), *PinInfo->PinName.ToString()));
R->SetStringField(TEXT("nodeId"), Node->NodeGuid.ToString());
R->SetStringField(TEXT("currentType"), PinInfo->PinType.PinCategory.ToString());
if (!ParamSubtype.IsEmpty())
R->SetStringField(TEXT("currentSubtype"), ParamSubtype);
if (bIsLevel)
R->SetBoolField(TEXT("isLevelBlueprint"), true);
Results.Add(MakeShared<FJsonValueObject>(R));
}
}
}
else if (auto* CustomEvent = Cast<UK2Node_CustomEvent>(Node))
{
for (const TSharedPtr<FUserPinInfo>& PinInfo : CustomEvent->UserDefinedPins)
{
if (!PinInfo.IsValid()) continue;
FString ParamSubtype;
if (PinInfo->PinType.PinSubCategoryObject.IsValid())
ParamSubtype = PinInfo->PinType.PinSubCategoryObject->GetName();
if (MatchesType(ParamSubtype) || MatchesType(PinInfo->PinType.PinCategory.ToString()))
{
TSharedRef<FJsonObject> R = MakeShared<FJsonObject>();
R->SetStringField(TEXT("blueprint"), BPName);
R->SetStringField(TEXT("blueprintPath"), Path);
R->SetStringField(TEXT("usage"), TEXT("eventParameter"));
R->SetStringField(TEXT("location"), FString::Printf(TEXT("%s.%s"),
*CustomEvent->CustomFunctionName.ToString(), *PinInfo->PinName.ToString()));
R->SetStringField(TEXT("nodeId"), Node->NodeGuid.ToString());
R->SetStringField(TEXT("currentType"), PinInfo->PinType.PinCategory.ToString());
if (!ParamSubtype.IsEmpty())
R->SetStringField(TEXT("currentSubtype"), ParamSubtype);
if (bIsLevel)
R->SetBoolField(TEXT("isLevelBlueprint"), true);
Results.Add(MakeShared<FJsonValueObject>(R));
}
}
}
// Check Break/Make struct nodes
else if (auto* BreakNode = Cast<UK2Node_BreakStruct>(Node))
{
if (BreakNode->StructType && MatchesType(BreakNode->StructType->GetName()))
{
TSharedRef<FJsonObject> R = MakeShared<FJsonObject>();
R->SetStringField(TEXT("blueprint"), BPName);
R->SetStringField(TEXT("blueprintPath"), Path);
R->SetStringField(TEXT("usage"), TEXT("breakStruct"));
R->SetStringField(TEXT("location"), Graph->GetName());
R->SetStringField(TEXT("nodeId"), Node->NodeGuid.ToString());
R->SetStringField(TEXT("structType"), BreakNode->StructType->GetName());
if (bIsLevel)
R->SetBoolField(TEXT("isLevelBlueprint"), true);
Results.Add(MakeShared<FJsonValueObject>(R));
}
}
else if (auto* MakeNode = Cast<UK2Node_MakeStruct>(Node))
{
if (MakeNode->StructType && MatchesType(MakeNode->StructType->GetName()))
{
TSharedRef<FJsonObject> R = MakeShared<FJsonObject>();
R->SetStringField(TEXT("blueprint"), BPName);
R->SetStringField(TEXT("blueprintPath"), Path);
R->SetStringField(TEXT("usage"), TEXT("makeStruct"));
R->SetStringField(TEXT("location"), Graph->GetName());
R->SetStringField(TEXT("nodeId"), Node->NodeGuid.ToString());
R->SetStringField(TEXT("structType"), MakeNode->StructType->GetName());
if (bIsLevel)
R->SetBoolField(TEXT("isLevelBlueprint"), true);
Results.Add(MakeShared<FJsonValueObject>(R));
}
}
// Check pin connections carrying the type
for (UEdGraphPin* Pin : Node->Pins)
{
if (!Pin || Pin->bHidden || Results.Num() >= MaxResults) continue;
FString PinSubtype;
if (Pin->PinType.PinSubCategoryObject.IsValid())
PinSubtype = Pin->PinType.PinSubCategoryObject->GetName();
if (Pin->LinkedTo.Num() > 0 &&
(MatchesType(PinSubtype) || MatchesType(Pin->PinType.PinCategory.ToString())))
{
TSharedRef<FJsonObject> R = MakeShared<FJsonObject>();
R->SetStringField(TEXT("blueprint"), BPName);
R->SetStringField(TEXT("blueprintPath"), Path);
R->SetStringField(TEXT("usage"), TEXT("pinConnection"));
R->SetStringField(TEXT("location"), FString::Printf(TEXT("%s.%s"),
*Node->GetNodeTitle(ENodeTitleType::FullTitle).ToString(),
*Pin->PinName.ToString()));
R->SetStringField(TEXT("nodeId"), Node->NodeGuid.ToString());
R->SetStringField(TEXT("graph"), Graph->GetName());
R->SetStringField(TEXT("pinType"), Pin->PinType.PinCategory.ToString());
if (!PinSubtype.IsEmpty())
R->SetStringField(TEXT("pinSubtype"), PinSubtype);
R->SetNumberField(TEXT("connectionCount"), Pin->LinkedTo.Num());
if (bIsLevel)
R->SetBoolField(TEXT("isLevelBlueprint"), true);
Results.Add(MakeShared<FJsonValueObject>(R));
}
}
}
}
};
// Search regular blueprints
for (const FAssetData& Asset : AllBlueprintAssets)
{
if (Results.Num() >= MaxResults) break;
FString Path = Asset.PackageName.ToString();
FString BPName = Asset.AssetName.ToString();
if (!FilterStr.IsEmpty() && !BPName.Contains(FilterStr, ESearchCase::IgnoreCase) &&
!Path.Contains(FilterStr, ESearchCase::IgnoreCase))
{
continue;
}
UBlueprint* BP = Cast<UBlueprint>(const_cast<FAssetData&>(Asset).GetAsset());
if (!BP) continue;
SearchOneBlueprint(BPName, Path, BP, false);
}
// Search level blueprints from maps
for (FAssetData& MapAsset : AllMapAssets)
{
if (Results.Num() >= MaxResults) break;
FString Path = MapAsset.PackageName.ToString();
FString MapName = MapAsset.AssetName.ToString();
if (!FilterStr.IsEmpty() && !MapName.Contains(FilterStr, ESearchCase::IgnoreCase) &&
!Path.Contains(FilterStr, ESearchCase::IgnoreCase))
{
continue;
}
UWorld* World = Cast<UWorld>(MapAsset.GetAsset());
if (!World || !World->PersistentLevel) continue;
ULevelScriptBlueprint* LevelBP = World->PersistentLevel->GetLevelScriptBlueprint(false);
if (!LevelBP) continue;
SearchOneBlueprint(MapName, Path, LevelBP, true);
}
TSharedRef<FJsonObject> Result = MakeShared<FJsonObject>();
Result->SetStringField(TEXT("typeName"), TypeName);
Result->SetNumberField(TEXT("resultCount"), Results.Num());
Result->SetArrayField(TEXT("results"), Results);
return JsonToString(Result);
}

View File

@@ -0,0 +1,418 @@
#include "BlueprintMCPServer.h"
#include "Engine/UserDefinedStruct.h"
#include "Engine/UserDefinedEnum.h"
#include "Kismet2/BlueprintEditorUtils.h"
#include "UserDefinedStructure/UserDefinedStructEditorData.h"
#include "Kismet2/EnumEditorUtils.h"
#include "Serialization/JsonReader.h"
#include "Serialization/JsonWriter.h"
#include "Serialization/JsonSerializer.h"
#include "UObject/SavePackage.h"
#include "Misc/PackageName.h"
#include "AssetRegistry/AssetRegistryModule.h"
#include "AssetRegistry/IAssetRegistry.h"
#include "AssetToolsModule.h"
#include "IAssetTools.h"
#include "Factories/StructureFactory.h"
#include "Factories/EnumFactory.h"
// ============================================================
// HandleCreateStruct — create a new UserDefinedStruct asset
// ============================================================
FString FBlueprintMCPServer::HandleCreateStruct(const FString& Body)
{
TSharedPtr<FJsonObject> Json = ParseBodyJson(Body);
if (!Json.IsValid())
{
return MakeErrorJson(TEXT("Invalid JSON body"));
}
FString AssetPath = Json->GetStringField(TEXT("assetPath"));
if (AssetPath.IsEmpty())
{
return MakeErrorJson(TEXT("Missing required field: assetPath (e.g. '/Game/DataTypes/S_MyStruct')"));
}
// Split path into package path and asset name
FString PackagePath, AssetName;
int32 LastSlash;
if (AssetPath.FindLastChar('/', LastSlash))
{
PackagePath = AssetPath.Left(LastSlash);
AssetName = AssetPath.Mid(LastSlash + 1);
}
else
{
return MakeErrorJson(TEXT("assetPath must be a full path (e.g. '/Game/DataTypes/S_MyStruct')"));
}
if (AssetName.IsEmpty())
{
return MakeErrorJson(TEXT("Invalid asset name in assetPath"));
}
// Check if asset already exists
FAssetRegistryModule& ARM = FModuleManager::LoadModuleChecked<FAssetRegistryModule>("AssetRegistry");
FAssetData ExistingAsset = ARM.Get().GetAssetByObjectPath(FSoftObjectPath(AssetPath + TEXT(".") + AssetName));
if (ExistingAsset.IsValid())
{
return MakeErrorJson(FString::Printf(TEXT("Asset already exists at '%s'"), *AssetPath));
}
// Create the struct using the AssetTools factory
FAssetToolsModule& AssetToolsModule = FModuleManager::LoadModuleChecked<FAssetToolsModule>("AssetTools");
IAssetTools& AssetTools = AssetToolsModule.Get();
UStructureFactory* Factory = NewObject<UStructureFactory>();
UObject* NewAsset = AssetTools.CreateAsset(AssetName, PackagePath, UUserDefinedStruct::StaticClass(), Factory);
if (!NewAsset)
{
return MakeErrorJson(TEXT("Failed to create UserDefinedStruct asset"));
}
UUserDefinedStruct* NewStruct = Cast<UUserDefinedStruct>(NewAsset);
if (!NewStruct)
{
return MakeErrorJson(TEXT("Created asset is not a UserDefinedStruct"));
}
// Add properties if specified
const TArray<TSharedPtr<FJsonValue>>* PropsArray = nullptr;
int32 PropsAdded = 0;
if (Json->TryGetArrayField(TEXT("properties"), PropsArray) && PropsArray)
{
for (const TSharedPtr<FJsonValue>& PropVal : *PropsArray)
{
TSharedPtr<FJsonObject> PropObj = PropVal->AsObject();
if (!PropObj) continue;
FString PropName = PropObj->GetStringField(TEXT("name"));
FString PropType = PropObj->GetStringField(TEXT("type"));
if (PropName.IsEmpty() || PropType.IsEmpty()) continue;
FEdGraphPinType PinType;
FString TypeError;
if (!ResolveTypeFromString(PropType, PinType, TypeError))
{
UE_LOG(LogTemp, Warning, TEXT("BlueprintMCP: Could not resolve type '%s' for property '%s': %s"), *PropType, *PropName, *TypeError);
continue;
}
// Snapshot existing GUIDs so we can find the newly added one
TSet<FGuid> ExistingGuids;
for (const FStructVariableDescription& Var : FStructureEditorUtils::GetVarDesc(NewStruct))
{
ExistingGuids.Add(Var.VarGuid);
}
bool bAdded = FStructureEditorUtils::AddVariable(NewStruct, PinType);
if (bAdded)
{
// Find the new variable by diffing GUID sets
FGuid NewPropGuid;
for (const FStructVariableDescription& Var : FStructureEditorUtils::GetVarDesc(NewStruct))
{
if (!ExistingGuids.Contains(Var.VarGuid))
{
NewPropGuid = Var.VarGuid;
break;
}
}
if (NewPropGuid.IsValid())
{
FStructureEditorUtils::RenameVariable(NewStruct, NewPropGuid, PropName);
}
PropsAdded++;
}
}
}
// Save
UPackage* Package = NewStruct->GetPackage();
FString PackageFilename = FPackageName::LongPackageNameToFilename(Package->GetName(), FPackageName::GetAssetPackageExtension());
FSavePackageArgs SaveArgs;
SaveArgs.TopLevelFlags = RF_Standalone;
bool bSaved = UPackage::SavePackage(Package, NewStruct, *PackageFilename, SaveArgs);
UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Created UserDefinedStruct '%s' with %d properties, save %s"),
*AssetPath, PropsAdded, bSaved ? TEXT("succeeded") : TEXT("failed"));
TSharedRef<FJsonObject> Result = MakeShared<FJsonObject>();
Result->SetBoolField(TEXT("success"), true);
Result->SetStringField(TEXT("assetPath"), AssetPath);
Result->SetStringField(TEXT("assetName"), AssetName);
Result->SetNumberField(TEXT("propertiesAdded"), PropsAdded);
Result->SetBoolField(TEXT("saved"), bSaved);
return JsonToString(Result);
}
// ============================================================
// HandleCreateEnum — create a new UserDefinedEnum asset
// ============================================================
FString FBlueprintMCPServer::HandleCreateEnum(const FString& Body)
{
TSharedPtr<FJsonObject> Json = ParseBodyJson(Body);
if (!Json.IsValid())
{
return MakeErrorJson(TEXT("Invalid JSON body"));
}
FString AssetPath = Json->GetStringField(TEXT("assetPath"));
if (AssetPath.IsEmpty())
{
return MakeErrorJson(TEXT("Missing required field: assetPath (e.g. '/Game/DataTypes/E_MyEnum')"));
}
// Split path
FString PackagePath, AssetName;
int32 LastSlash;
if (AssetPath.FindLastChar('/', LastSlash))
{
PackagePath = AssetPath.Left(LastSlash);
AssetName = AssetPath.Mid(LastSlash + 1);
}
else
{
return MakeErrorJson(TEXT("assetPath must be a full path (e.g. '/Game/DataTypes/E_MyEnum')"));
}
if (AssetName.IsEmpty())
{
return MakeErrorJson(TEXT("Invalid asset name in assetPath"));
}
// Get values
const TArray<TSharedPtr<FJsonValue>>* ValuesArray = nullptr;
if (!Json->TryGetArrayField(TEXT("values"), ValuesArray) || !ValuesArray || ValuesArray->Num() == 0)
{
return MakeErrorJson(TEXT("Missing or empty required field: values (array of strings)"));
}
TArray<FString> EnumValues;
for (const TSharedPtr<FJsonValue>& Val : *ValuesArray)
{
FString Str = Val->AsString();
if (!Str.IsEmpty()) EnumValues.Add(Str);
}
if (EnumValues.Num() == 0)
{
return MakeErrorJson(TEXT("No valid enum values provided"));
}
// Create the enum using AssetTools
FAssetToolsModule& AssetToolsModule = FModuleManager::LoadModuleChecked<FAssetToolsModule>("AssetTools");
IAssetTools& AssetTools = AssetToolsModule.Get();
UEnumFactory* Factory = NewObject<UEnumFactory>();
UObject* NewAsset = AssetTools.CreateAsset(AssetName, PackagePath, UUserDefinedEnum::StaticClass(), Factory);
if (!NewAsset)
{
return MakeErrorJson(TEXT("Failed to create UserDefinedEnum asset"));
}
UUserDefinedEnum* NewEnum = Cast<UUserDefinedEnum>(NewAsset);
if (!NewEnum)
{
return MakeErrorJson(TEXT("Created asset is not a UserDefinedEnum"));
}
// Add enum values — UUserDefinedEnum starts with a MAX value.
// We need to add entries before MAX.
for (int32 i = 0; i < EnumValues.Num(); ++i)
{
// AddNewEnumeratorForUserDefinedEnum adds before the _MAX entry (returns void)
FEnumEditorUtils::AddNewEnumeratorForUserDefinedEnum(NewEnum);
// The new entry is at index (NumEnums - 2) because _MAX is last
int32 NewIndex = NewEnum->NumEnums() - 2;
FEnumEditorUtils::SetEnumeratorDisplayName(NewEnum, NewIndex, FText::FromString(EnumValues[i]));
}
// Save
UPackage* Package = NewEnum->GetPackage();
FString PackageFilename = FPackageName::LongPackageNameToFilename(Package->GetName(), FPackageName::GetAssetPackageExtension());
FSavePackageArgs SaveArgs;
SaveArgs.TopLevelFlags = RF_Standalone;
bool bSaved = UPackage::SavePackage(Package, NewEnum, *PackageFilename, SaveArgs);
UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Created UserDefinedEnum '%s' with %d values, save %s"),
*AssetPath, EnumValues.Num(), bSaved ? TEXT("succeeded") : TEXT("failed"));
TSharedRef<FJsonObject> Result = MakeShared<FJsonObject>();
Result->SetBoolField(TEXT("success"), true);
Result->SetStringField(TEXT("assetPath"), AssetPath);
Result->SetStringField(TEXT("assetName"), AssetName);
Result->SetNumberField(TEXT("valueCount"), EnumValues.Num());
Result->SetBoolField(TEXT("saved"), bSaved);
return JsonToString(Result);
}
// ============================================================
// HandleAddStructProperty — add a property to UserDefinedStruct
// ============================================================
FString FBlueprintMCPServer::HandleAddStructProperty(const FString& Body)
{
TSharedPtr<FJsonObject> Json = ParseBodyJson(Body);
if (!Json.IsValid())
{
return MakeErrorJson(TEXT("Invalid JSON body"));
}
FString AssetPath = Json->GetStringField(TEXT("assetPath"));
FString PropName = Json->GetStringField(TEXT("name"));
FString PropType = Json->GetStringField(TEXT("type"));
if (AssetPath.IsEmpty() || PropName.IsEmpty() || PropType.IsEmpty())
{
return MakeErrorJson(TEXT("Missing required fields: assetPath, name, type"));
}
// Find the struct
UUserDefinedStruct* Struct = LoadObject<UUserDefinedStruct>(nullptr, *AssetPath);
if (!Struct)
{
// Try with asset name appended
FString FullPath = AssetPath + TEXT(".") + FPackageName::GetShortName(AssetPath);
Struct = LoadObject<UUserDefinedStruct>(nullptr, *FullPath);
}
if (!Struct)
{
return MakeErrorJson(FString::Printf(TEXT("UserDefinedStruct not found at '%s'"), *AssetPath));
}
// Resolve type
FEdGraphPinType PinType;
FString TypeError;
if (!ResolveTypeFromString(PropType, PinType, TypeError))
{
return MakeErrorJson(FString::Printf(TEXT("Cannot resolve type '%s': %s"), *PropType, *TypeError));
}
// Snapshot existing GUIDs so we can find the newly added one
TSet<FGuid> ExistingGuids;
for (const FStructVariableDescription& Var : FStructureEditorUtils::GetVarDesc(Struct))
{
ExistingGuids.Add(Var.VarGuid);
}
bool bAdded = FStructureEditorUtils::AddVariable(Struct, PinType);
if (!bAdded)
{
return MakeErrorJson(TEXT("Failed to add property to struct"));
}
// Find the new variable by diffing GUID sets and rename it
for (const FStructVariableDescription& Var : FStructureEditorUtils::GetVarDesc(Struct))
{
if (!ExistingGuids.Contains(Var.VarGuid))
{
FStructureEditorUtils::RenameVariable(Struct, Var.VarGuid, PropName);
break;
}
}
// Save
UPackage* Package = Struct->GetPackage();
FString PackageFilename = FPackageName::LongPackageNameToFilename(Package->GetName(), FPackageName::GetAssetPackageExtension());
FSavePackageArgs SaveArgs;
SaveArgs.TopLevelFlags = RF_Standalone;
bool bSaved = UPackage::SavePackage(Package, Struct, *PackageFilename, SaveArgs);
UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Added property '%s' (%s) to struct '%s', save %s"),
*PropName, *PropType, *AssetPath, bSaved ? TEXT("succeeded") : TEXT("failed"));
TSharedRef<FJsonObject> Result = MakeShared<FJsonObject>();
Result->SetBoolField(TEXT("success"), true);
Result->SetStringField(TEXT("assetPath"), AssetPath);
Result->SetStringField(TEXT("propertyName"), PropName);
Result->SetStringField(TEXT("propertyType"), PropType);
Result->SetBoolField(TEXT("saved"), bSaved);
return JsonToString(Result);
}
// ============================================================
// HandleRemoveStructProperty — remove a property from UserDefinedStruct
// ============================================================
FString FBlueprintMCPServer::HandleRemoveStructProperty(const FString& Body)
{
TSharedPtr<FJsonObject> Json = ParseBodyJson(Body);
if (!Json.IsValid())
{
return MakeErrorJson(TEXT("Invalid JSON body"));
}
FString AssetPath = Json->GetStringField(TEXT("assetPath"));
FString PropName = Json->GetStringField(TEXT("name"));
if (AssetPath.IsEmpty() || PropName.IsEmpty())
{
return MakeErrorJson(TEXT("Missing required fields: assetPath, name"));
}
// Find the struct
UUserDefinedStruct* Struct = LoadObject<UUserDefinedStruct>(nullptr, *AssetPath);
if (!Struct)
{
FString FullPath = AssetPath + TEXT(".") + FPackageName::GetShortName(AssetPath);
Struct = LoadObject<UUserDefinedStruct>(nullptr, *FullPath);
}
if (!Struct)
{
return MakeErrorJson(FString::Printf(TEXT("UserDefinedStruct not found at '%s'"), *AssetPath));
}
// Find the property GUID by name
FGuid TargetGuid;
bool bFound = false;
for (const FStructVariableDescription& Var : FStructureEditorUtils::GetVarDesc(Struct))
{
if (Var.FriendlyName == PropName || Var.VarName.ToString() == PropName)
{
TargetGuid = Var.VarGuid;
bFound = true;
break;
}
}
if (!bFound)
{
// List available properties
TArray<TSharedPtr<FJsonValue>> AvailProps;
for (const FStructVariableDescription& Var : FStructureEditorUtils::GetVarDesc(Struct))
{
AvailProps.Add(MakeShared<FJsonValueString>(Var.FriendlyName));
}
TSharedRef<FJsonObject> E = MakeShared<FJsonObject>();
E->SetStringField(TEXT("error"), FString::Printf(TEXT("Property '%s' not found in struct '%s'"), *PropName, *AssetPath));
E->SetArrayField(TEXT("availableProperties"), AvailProps);
return JsonToString(E);
}
bool bRemoved = FStructureEditorUtils::RemoveVariable(Struct, TargetGuid);
if (!bRemoved)
{
return MakeErrorJson(FString::Printf(TEXT("Failed to remove property '%s'"), *PropName));
}
// Save
UPackage* Package = Struct->GetPackage();
FString PackageFilename = FPackageName::LongPackageNameToFilename(Package->GetName(), FPackageName::GetAssetPackageExtension());
FSavePackageArgs SaveArgs;
SaveArgs.TopLevelFlags = RF_Standalone;
bool bSaved = UPackage::SavePackage(Package, Struct, *PackageFilename, SaveArgs);
UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Removed property '%s' from struct '%s', save %s"),
*PropName, *AssetPath, bSaved ? TEXT("succeeded") : TEXT("failed"));
TSharedRef<FJsonObject> Result = MakeShared<FJsonObject>();
Result->SetBoolField(TEXT("success"), true);
Result->SetStringField(TEXT("assetPath"), AssetPath);
Result->SetStringField(TEXT("removedProperty"), PropName);
Result->SetBoolField(TEXT("saved"), bSaved);
return JsonToString(Result);
}

View File

@@ -0,0 +1,336 @@
#include "BlueprintMCPServer.h"
#include "Engine/Blueprint.h"
#include "EdGraph/EdGraph.h"
#include "EdGraph/EdGraphNode.h"
#include "Kismet2/BlueprintEditorUtils.h"
#include "Kismet2/KismetEditorUtilities.h"
#include "Serialization/JsonReader.h"
#include "Serialization/JsonWriter.h"
#include "Serialization/JsonSerializer.h"
// SEH wrapper defined in BlueprintMCPServer.cpp — non-static for cross-TU access
#if PLATFORM_WINDOWS
extern int32 TryCompileBlueprintSEH(UBlueprint* BP, EBlueprintCompileOptions Opts);
#endif
// ============================================================
// Log capture device for intercepting UE_LOG output during compilation
// ============================================================
class FCompileLogCapture : public FOutputDevice
{
public:
TArray<FString> CapturedErrors;
TArray<FString> CapturedWarnings;
virtual void Serialize(const TCHAR* V, ELogVerbosity::Type Verbosity, const FName& Category) override
{
FString Msg(V);
if (Verbosity == ELogVerbosity::Error || Verbosity == ELogVerbosity::Fatal)
{
CapturedErrors.Add(Msg);
return;
}
if (Verbosity == ELogVerbosity::Warning)
{
if (!Msg.Contains(TEXT("BlueprintMCP:")))
{
CapturedWarnings.Add(Msg);
}
return;
}
static const TCHAR* ErrorPatterns[] = {
TEXT("Can't connect pins"),
TEXT("Fixed up function"),
TEXT("is not compatible with"),
TEXT("could not find a pin"),
TEXT("has an invalid"),
TEXT("orphaned pin"),
TEXT("is deprecated"),
TEXT("does not implement"),
TEXT("Missing function"),
TEXT("Unable to find"),
TEXT("Failed to resolve"),
};
for (const TCHAR* Pattern : ErrorPatterns)
{
if (Msg.Contains(Pattern))
{
CapturedWarnings.Add(Msg);
return;
}
}
}
};
// Helper: validate a single Blueprint and return structured JSON result
static TSharedRef<FJsonObject> ValidateSingleBlueprint(UBlueprint* BP, const FString& BlueprintName)
{
FCompileLogCapture LogCapture;
GLog->AddOutputDevice(&LogCapture);
EBlueprintCompileOptions CompileOpts =
EBlueprintCompileOptions::SkipSave |
EBlueprintCompileOptions::SkipGarbageCollection |
EBlueprintCompileOptions::SkipFiBSearchMetaUpdate;
bool bCompileCrashed = false;
#if PLATFORM_WINDOWS
int32 CompileResult = TryCompileBlueprintSEH(BP, CompileOpts);
if (CompileResult != 0)
{
bCompileCrashed = true;
}
#else
FKismetEditorUtilities::CompileBlueprint(BP, CompileOpts, nullptr);
#endif
GLog->RemoveOutputDevice(&LogCapture);
TArray<TSharedPtr<FJsonValue>> ErrorsArr;
TArray<TSharedPtr<FJsonValue>> WarningsArr;
TArray<UEdGraph*> AllGraphs;
BP->GetAllGraphs(AllGraphs);
for (UEdGraph* Graph : AllGraphs)
{
if (!Graph) continue;
for (UEdGraphNode* Node : Graph->Nodes)
{
if (!Node) continue;
if (Node->bHasCompilerMessage)
{
TSharedRef<FJsonObject> Msg = MakeShared<FJsonObject>();
Msg->SetStringField(TEXT("graph"), Graph->GetName());
Msg->SetStringField(TEXT("nodeId"), Node->NodeGuid.ToString());
Msg->SetStringField(TEXT("nodeTitle"), Node->GetNodeTitle(ENodeTitleType::FullTitle).ToString());
Msg->SetStringField(TEXT("nodeClass"), Node->GetClass()->GetName());
Msg->SetStringField(TEXT("message"), Node->ErrorMsg);
if (Node->ErrorType == EMessageSeverity::Error)
{
Msg->SetStringField(TEXT("severity"), TEXT("error"));
ErrorsArr.Add(MakeShared<FJsonValueObject>(Msg));
}
else
{
Msg->SetStringField(TEXT("severity"), TEXT("warning"));
WarningsArr.Add(MakeShared<FJsonValueObject>(Msg));
}
}
}
}
for (const FString& LogErr : LogCapture.CapturedErrors)
{
TSharedRef<FJsonObject> Msg = MakeShared<FJsonObject>();
Msg->SetStringField(TEXT("source"), TEXT("log"));
Msg->SetStringField(TEXT("message"), LogErr);
Msg->SetStringField(TEXT("severity"), TEXT("error"));
ErrorsArr.Add(MakeShared<FJsonValueObject>(Msg));
}
for (const FString& LogWarn : LogCapture.CapturedWarnings)
{
TSharedRef<FJsonObject> Msg = MakeShared<FJsonObject>();
Msg->SetStringField(TEXT("source"), TEXT("log"));
Msg->SetStringField(TEXT("message"), LogWarn);
Msg->SetStringField(TEXT("severity"), TEXT("warning"));
WarningsArr.Add(MakeShared<FJsonValueObject>(Msg));
}
FString StatusStr;
switch (BP->Status)
{
case BS_UpToDate: StatusStr = TEXT("UpToDate"); break;
case BS_Dirty: StatusStr = TEXT("Dirty"); break;
case BS_Error: StatusStr = TEXT("Error"); break;
case BS_Unknown: StatusStr = TEXT("Unknown"); break;
default: StatusStr = FString::Printf(TEXT("Status_%d"), (int32)BP->Status); break;
}
bool bIsValid = (BP->Status == BS_UpToDate) && ErrorsArr.Num() == 0;
TSharedRef<FJsonObject> Result = MakeShared<FJsonObject>();
Result->SetStringField(TEXT("blueprint"), BlueprintName);
Result->SetStringField(TEXT("status"), StatusStr);
Result->SetBoolField(TEXT("isValid"), bIsValid);
Result->SetNumberField(TEXT("errorCount"), ErrorsArr.Num());
Result->SetArrayField(TEXT("errors"), ErrorsArr);
Result->SetNumberField(TEXT("warningCount"), WarningsArr.Num());
Result->SetArrayField(TEXT("warnings"), WarningsArr);
if (bCompileCrashed)
{
Result->SetStringField(TEXT("compileWarning"), TEXT("Compilation crashed (SEH caught), results may be incomplete"));
}
return Result;
}
// HandleValidateBlueprint — compile without saving, report errors + captured log messages
// ============================================================
FString FBlueprintMCPServer::HandleValidateBlueprint(const FString& Body)
{
TSharedPtr<FJsonObject> Json = ParseBodyJson(Body);
if (!Json.IsValid())
{
return MakeErrorJson(TEXT("Invalid JSON body"));
}
FString BlueprintName = Json->GetStringField(TEXT("blueprint"));
if (BlueprintName.IsEmpty())
{
return MakeErrorJson(TEXT("Missing required field: blueprint"));
}
// Load Blueprint
FString LoadError;
UBlueprint* BP = LoadBlueprintByName(BlueprintName, LoadError);
if (!BP)
{
return MakeErrorJson(LoadError);
}
UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Validating blueprint '%s'"), *BlueprintName);
TSharedRef<FJsonObject> Result = ValidateSingleBlueprint(BP, BlueprintName);
return JsonToString(Result);
}
// ============================================================
// HandleValidateAllBlueprints — bulk validation
// ============================================================
FString FBlueprintMCPServer::HandleValidateAllBlueprints(const FString& Body)
{
TSharedPtr<FJsonObject> Json = ParseBodyJson(Body);
// Body is optional — empty body means validate all
FString Filter;
bool bCountOnly = false;
int32 Offset = 0;
int32 Limit = 0;
if (Json.IsValid())
{
Filter = Json->GetStringField(TEXT("filter"));
bCountOnly = Json->GetBoolField(TEXT("countOnly"));
Offset = (int32)Json->GetNumberField(TEXT("offset"));
Limit = (int32)Json->GetNumberField(TEXT("limit"));
}
// First pass: collect matching asset indices (string comparisons only, no GetAsset())
TArray<int32> MatchingIndices;
for (int32 i = 0; i < AllBlueprintAssets.Num(); i++)
{
const FAssetData& Asset = AllBlueprintAssets[i];
if (!Filter.IsEmpty())
{
FString AssetName = Asset.AssetName.ToString();
FString PackagePath = Asset.PackageName.ToString();
if (!PackagePath.Contains(Filter) && !AssetName.Contains(Filter))
{
continue;
}
}
MatchingIndices.Add(i);
}
int32 TotalMatching = MatchingIndices.Num();
// countOnly: return count without compiling anything
if (bCountOnly)
{
TSharedRef<FJsonObject> Result = MakeShared<FJsonObject>();
Result->SetNumberField(TEXT("totalMatching"), TotalMatching);
if (!Filter.IsEmpty())
{
Result->SetStringField(TEXT("filter"), Filter);
}
return JsonToString(Result);
}
// Compute range
int32 StartIdx = FMath::Clamp(Offset, 0, TotalMatching);
int32 EndIdx = (Limit > 0) ? FMath::Min(StartIdx + Limit, TotalMatching) : TotalMatching;
UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Bulk validating blueprints (filter: '%s', range: %d-%d of %d matching)"),
Filter.IsEmpty() ? TEXT("*") : *Filter, StartIdx, EndIdx, TotalMatching);
TArray<TSharedPtr<FJsonValue>> FailedArr;
int32 TotalChecked = 0;
int32 TotalPassed = 0;
int32 TotalFailed = 0;
int32 TotalCrashed = 0;
for (int32 Idx = StartIdx; Idx < EndIdx; Idx++)
{
const FAssetData& Asset = AllBlueprintAssets[MatchingIndices[Idx]];
FString AssetName = Asset.AssetName.ToString();
FString PackagePath = Asset.PackageName.ToString();
// Load the Blueprint
UBlueprint* BP = Cast<UBlueprint>(Asset.GetAsset());
if (!BP)
{
continue;
}
TotalChecked++;
TSharedRef<FJsonObject> Result = ValidateSingleBlueprint(BP, AssetName);
bool bValid = Result->GetBoolField(TEXT("isValid"));
int32 Errors = (int32)Result->GetNumberField(TEXT("errorCount"));
int32 Warnings = (int32)Result->GetNumberField(TEXT("warningCount"));
if (Result->HasField(TEXT("compileWarning")))
{
TotalCrashed++;
}
if (bValid && Errors == 0)
{
TotalPassed++;
}
else
{
TotalFailed++;
// Include path for context in bulk results
Result->SetStringField(TEXT("path"), PackagePath);
FailedArr.Add(MakeShared<FJsonValueObject>(Result));
}
// Log progress every 50 blueprints
if (TotalChecked % 50 == 0)
{
UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Validated %d blueprints so far (%d failed)..."),
TotalChecked, TotalFailed);
}
}
UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Bulk validation complete — %d checked, %d passed, %d failed, %d crashed"),
TotalChecked, TotalPassed, TotalFailed, TotalCrashed);
TSharedRef<FJsonObject> Result = MakeShared<FJsonObject>();
Result->SetNumberField(TEXT("totalMatching"), TotalMatching);
Result->SetNumberField(TEXT("totalChecked"), TotalChecked);
Result->SetNumberField(TEXT("totalPassed"), TotalPassed);
Result->SetNumberField(TEXT("totalFailed"), TotalFailed);
if (TotalCrashed > 0)
{
Result->SetNumberField(TEXT("totalCrashed"), TotalCrashed);
}
Result->SetArrayField(TEXT("failed"), FailedArr);
if (!Filter.IsEmpty())
{
Result->SetStringField(TEXT("filter"), Filter);
}
return JsonToString(Result);
}

View File

@@ -0,0 +1,622 @@
#include "BlueprintMCPServer.h"
#include "Engine/Blueprint.h"
#include "EdGraph/EdGraph.h"
#include "EdGraph/EdGraphPin.h"
#include "K2Node_VariableGet.h"
#include "K2Node_VariableSet.h"
#include "Kismet2/BlueprintEditorUtils.h"
#include "Kismet2/KismetEditorUtilities.h"
#include "Serialization/JsonReader.h"
#include "Serialization/JsonWriter.h"
#include "Serialization/JsonSerializer.h"
#include "UObject/UObjectIterator.h"
// ============================================================
// HandleChangeVariableType — change a Blueprint member variable's type
// ============================================================
FString FBlueprintMCPServer::HandleChangeVariableType(const FString& Body)
{
TSharedPtr<FJsonObject> Json = ParseBodyJson(Body);
if (!Json.IsValid())
{
return MakeErrorJson(TEXT("Invalid JSON body"));
}
FString BlueprintName = Json->GetStringField(TEXT("blueprint"));
FString VariableName = Json->GetStringField(TEXT("variable"));
FString NewTypeName = Json->GetStringField(TEXT("newType"));
FString TypeCategory; // now optional
if (Json->HasField(TEXT("typeCategory")))
{
TypeCategory = Json->GetStringField(TEXT("typeCategory"));
}
if (BlueprintName.IsEmpty() || VariableName.IsEmpty() || NewTypeName.IsEmpty())
{
return MakeErrorJson(TEXT("Missing required fields: blueprint, variable, newType"));
}
// Load Blueprint
FString LoadError;
UBlueprint* BP = LoadBlueprintByName(BlueprintName, LoadError);
if (!BP)
{
return MakeErrorJson(LoadError);
}
// Verify variable exists
bool bVarFound = false;
for (const FBPVariableDescription& Var : BP->NewVariables)
{
if (Var.VarName.ToString() == VariableName)
{
bVarFound = true;
break;
}
}
if (!bVarFound)
{
return MakeErrorJson(FString::Printf(TEXT("Variable '%s' not found in Blueprint '%s'"), *VariableName, *BlueprintName));
}
// Build the new pin type using shared resolver
FEdGraphPinType NewPinType;
FString ResolveInput = NewTypeName;
// If typeCategory is an object reference variant, use colon syntax for the resolver
if (TypeCategory == TEXT("object") || TypeCategory == TEXT("softobject") ||
TypeCategory == TEXT("class") || TypeCategory == TEXT("softclass") ||
TypeCategory == TEXT("interface"))
{
ResolveInput = TypeCategory + TEXT(":") + NewTypeName;
}
FString TypeError;
if (!ResolveTypeFromString(ResolveInput, NewPinType, TypeError))
{
return MakeErrorJson(TypeError);
}
// Derive typeCategory from the resolved pin type for the response
if (TypeCategory.IsEmpty())
{
if (NewPinType.PinCategory == UEdGraphSchema_K2::PC_Struct)
TypeCategory = TEXT("struct");
else if (NewPinType.PinCategory == UEdGraphSchema_K2::PC_Enum || NewPinType.PinCategory == UEdGraphSchema_K2::PC_Byte)
TypeCategory = TEXT("enum");
else if (NewPinType.PinCategory == UEdGraphSchema_K2::PC_Object)
TypeCategory = TEXT("object");
else if (NewPinType.PinCategory == UEdGraphSchema_K2::PC_SoftObject)
TypeCategory = TEXT("softobject");
else if (NewPinType.PinCategory == UEdGraphSchema_K2::PC_Class)
TypeCategory = TEXT("class");
else if (NewPinType.PinCategory == UEdGraphSchema_K2::PC_SoftClass)
TypeCategory = TEXT("softclass");
else if (NewPinType.PinCategory == UEdGraphSchema_K2::PC_Interface)
TypeCategory = TEXT("interface");
else
TypeCategory = NewPinType.PinCategory.ToString();
}
// Check for dry run
bool bDryRun = false;
if (Json->HasField(TEXT("dryRun")))
{
bDryRun = Json->GetBoolField(TEXT("dryRun"));
}
UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: %s variable '%s' in '%s' to %s (%s)"),
bDryRun ? TEXT("[DRY RUN] Analyzing change of") : TEXT("Changing"),
*VariableName, *BlueprintName, *NewTypeName, *TypeCategory);
// Analyze affected nodes (get/set nodes for this variable)
TArray<TSharedPtr<FJsonValue>> AffectedNodes;
TArray<UEdGraph*> AllGraphs;
BP->GetAllGraphs(AllGraphs);
for (UEdGraph* Graph : AllGraphs)
{
if (!Graph) continue;
for (UEdGraphNode* Node : Graph->Nodes)
{
if (!Node) continue;
if (auto* VG = Cast<UK2Node_VariableGet>(Node))
{
if (VG->GetVarName().ToString() == VariableName)
{
TSharedRef<FJsonObject> AffNode = MakeShared<FJsonObject>();
AffNode->SetStringField(TEXT("nodeId"), VG->NodeGuid.ToString());
AffNode->SetStringField(TEXT("nodeType"), TEXT("VariableGet"));
AffNode->SetStringField(TEXT("graph"), Graph->GetName());
// Check which pins would be affected
TArray<TSharedPtr<FJsonValue>> AffPins;
for (UEdGraphPin* Pin : VG->Pins)
{
if (Pin && Pin->LinkedTo.Num() > 0 && Pin->Direction == EGPD_Output)
{
AffPins.Add(MakeShared<FJsonValueString>(
FString::Printf(TEXT("%s (connected to %d pin(s))"),
*Pin->PinName.ToString(), Pin->LinkedTo.Num())));
}
}
AffNode->SetArrayField(TEXT("affectedPins"), AffPins);
AffectedNodes.Add(MakeShared<FJsonValueObject>(AffNode));
}
}
else if (auto* VS = Cast<UK2Node_VariableSet>(Node))
{
if (VS->GetVarName().ToString() == VariableName)
{
TSharedRef<FJsonObject> AffNode = MakeShared<FJsonObject>();
AffNode->SetStringField(TEXT("nodeId"), VS->NodeGuid.ToString());
AffNode->SetStringField(TEXT("nodeType"), TEXT("VariableSet"));
AffNode->SetStringField(TEXT("graph"), Graph->GetName());
TArray<TSharedPtr<FJsonValue>> AffPins;
for (UEdGraphPin* Pin : VS->Pins)
{
if (Pin && Pin->LinkedTo.Num() > 0)
{
AffPins.Add(MakeShared<FJsonValueString>(
FString::Printf(TEXT("%s (connected to %d pin(s))"),
*Pin->PinName.ToString(), Pin->LinkedTo.Num())));
}
}
AffNode->SetArrayField(TEXT("affectedPins"), AffPins);
AffectedNodes.Add(MakeShared<FJsonValueObject>(AffNode));
}
}
}
}
if (bDryRun)
{
TSharedRef<FJsonObject> Result = MakeShared<FJsonObject>();
Result->SetBoolField(TEXT("dryRun"), true);
Result->SetStringField(TEXT("blueprint"), BlueprintName);
Result->SetStringField(TEXT("variable"), VariableName);
Result->SetStringField(TEXT("newType"), NewTypeName);
Result->SetStringField(TEXT("typeCategory"), TypeCategory);
Result->SetNumberField(TEXT("affectedNodeCount"), AffectedNodes.Num());
Result->SetArrayField(TEXT("affectedNodes"), AffectedNodes);
return JsonToString(Result);
}
// Directly modify the variable type in the description array.
for (FBPVariableDescription& Var : BP->NewVariables)
{
if (Var.VarName == FName(*VariableName))
{
Var.VarType = NewPinType;
break;
}
}
// Save
bool bSaved = SaveBlueprintPackage(BP);
UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Variable type changed, save %s"),
bSaved ? TEXT("succeeded") : TEXT("failed"));
// Return updated variable state
TSharedRef<FJsonObject> UpdatedVar = MakeShared<FJsonObject>();
for (const FBPVariableDescription& Var : BP->NewVariables)
{
if (Var.VarName == FName(*VariableName))
{
UpdatedVar->SetStringField(TEXT("name"), Var.VarName.ToString());
UpdatedVar->SetStringField(TEXT("type"), Var.VarType.PinCategory.ToString());
if (Var.VarType.PinSubCategoryObject.IsValid())
UpdatedVar->SetStringField(TEXT("subtype"), Var.VarType.PinSubCategoryObject->GetName());
UpdatedVar->SetBoolField(TEXT("isArray"), Var.VarType.IsArray());
break;
}
}
TSharedRef<FJsonObject> Result = MakeShared<FJsonObject>();
Result->SetBoolField(TEXT("success"), true);
Result->SetStringField(TEXT("blueprint"), BlueprintName);
Result->SetStringField(TEXT("variable"), VariableName);
Result->SetStringField(TEXT("newType"), NewTypeName);
Result->SetStringField(TEXT("typeCategory"), TypeCategory);
Result->SetBoolField(TEXT("saved"), bSaved);
Result->SetObjectField(TEXT("updatedVariable"), UpdatedVar);
Result->SetArrayField(TEXT("affectedNodes"), AffectedNodes);
return JsonToString(Result);
}
// ============================================================
// HandleAddVariable — add a new member variable to a Blueprint
// ============================================================
FString FBlueprintMCPServer::HandleAddVariable(const FString& Body)
{
TSharedPtr<FJsonObject> Json = ParseBodyJson(Body);
if (!Json.IsValid())
{
return MakeErrorJson(TEXT("Invalid JSON body"));
}
FString BlueprintName = Json->GetStringField(TEXT("blueprint"));
FString VariableName = Json->GetStringField(TEXT("variableName"));
FString VariableType = Json->GetStringField(TEXT("variableType"));
if (BlueprintName.IsEmpty() || VariableName.IsEmpty() || VariableType.IsEmpty())
{
return MakeErrorJson(TEXT("Missing required fields: blueprint, variableName, variableType"));
}
FString Category;
if (Json->HasField(TEXT("category")))
{
Category = Json->GetStringField(TEXT("category"));
}
bool bIsArray = false;
if (Json->HasField(TEXT("isArray")))
{
bIsArray = Json->GetBoolField(TEXT("isArray"));
}
FString DefaultValue;
if (Json->HasField(TEXT("defaultValue")))
{
DefaultValue = Json->GetStringField(TEXT("defaultValue"));
}
// Load Blueprint
FString LoadError;
UBlueprint* BP = LoadBlueprintByName(BlueprintName, LoadError);
if (!BP)
{
return MakeErrorJson(LoadError);
}
// Check for duplicate variable name
FName VarFName(*VariableName);
for (const FBPVariableDescription& Var : BP->NewVariables)
{
if (Var.VarName == VarFName)
{
return MakeErrorJson(FString::Printf(
TEXT("Variable '%s' already exists in Blueprint '%s'"), *VariableName, *BlueprintName));
}
}
// Resolve the type using the shared helper
FEdGraphPinType PinType;
FString TypeError;
if (!ResolveTypeFromString(VariableType, PinType, TypeError))
{
return MakeErrorJson(TypeError);
}
// Set container type for arrays
if (bIsArray)
{
PinType.ContainerType = EPinContainerType::Array;
}
UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Adding variable '%s' (type=%s, array=%s) to Blueprint '%s'"),
*VariableName, *VariableType, bIsArray ? TEXT("true") : TEXT("false"), *BlueprintName);
// Add the variable using the editor utility function
bool bSuccess = FBlueprintEditorUtils::AddMemberVariable(BP, VarFName, PinType, DefaultValue);
if (!bSuccess)
{
return MakeErrorJson(FString::Printf(
TEXT("FBlueprintEditorUtils::AddMemberVariable failed for '%s'"), *VariableName));
}
// Set category if provided
if (!Category.IsEmpty())
{
FBlueprintEditorUtils::SetBlueprintVariableCategory(BP, VarFName, nullptr, FText::FromString(Category));
}
FBlueprintEditorUtils::MarkBlueprintAsStructurallyModified(BP);
bool bSaved = SaveBlueprintPackage(BP);
UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Added variable '%s' to '%s' (saved: %s)"),
*VariableName, *BlueprintName, bSaved ? TEXT("true") : TEXT("false"));
TSharedRef<FJsonObject> Result = MakeShared<FJsonObject>();
Result->SetBoolField(TEXT("success"), true);
Result->SetStringField(TEXT("blueprint"), BlueprintName);
Result->SetStringField(TEXT("variableName"), VariableName);
Result->SetStringField(TEXT("variableType"), VariableType);
if (!Category.IsEmpty())
{
Result->SetStringField(TEXT("category"), Category);
}
Result->SetBoolField(TEXT("isArray"), bIsArray);
Result->SetBoolField(TEXT("saved"), bSaved);
return JsonToString(Result);
}
// ============================================================
// HandleRemoveVariable — remove a member variable from a Blueprint
// ============================================================
FString FBlueprintMCPServer::HandleRemoveVariable(const FString& Body)
{
TSharedPtr<FJsonObject> Json = ParseBodyJson(Body);
if (!Json.IsValid())
{
return MakeErrorJson(TEXT("Invalid JSON body"));
}
FString BlueprintName = Json->GetStringField(TEXT("blueprint"));
FString VariableName = Json->GetStringField(TEXT("variableName"));
if (BlueprintName.IsEmpty() || VariableName.IsEmpty())
{
return MakeErrorJson(TEXT("Missing required fields: blueprint, variableName"));
}
// Load Blueprint
FString LoadError;
UBlueprint* BP = LoadBlueprintByName(BlueprintName, LoadError);
if (!BP)
{
return MakeErrorJson(LoadError);
}
// Find variable by name (case-insensitive)
FName VarFName(*VariableName);
bool bVarFound = false;
for (const FBPVariableDescription& Var : BP->NewVariables)
{
if (Var.VarName.ToString().Equals(VariableName, ESearchCase::IgnoreCase))
{
VarFName = Var.VarName; // Use the exact name found
bVarFound = true;
break;
}
}
if (!bVarFound)
{
// Build available variables list for helpful error message
TArray<TSharedPtr<FJsonValue>> AvailVars;
for (const FBPVariableDescription& Var : BP->NewVariables)
{
AvailVars.Add(MakeShared<FJsonValueString>(Var.VarName.ToString()));
}
TSharedRef<FJsonObject> ErrorResult = MakeShared<FJsonObject>();
ErrorResult->SetStringField(TEXT("error"), FString::Printf(
TEXT("Variable '%s' not found in Blueprint '%s'"), *VariableName, *BlueprintName));
ErrorResult->SetArrayField(TEXT("availableVariables"), AvailVars);
return JsonToString(ErrorResult);
}
UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Removing variable '%s' from Blueprint '%s'"),
*VariableName, *BlueprintName);
// Use the editor utility to remove the variable (also cleans up Get/Set nodes)
FBlueprintEditorUtils::RemoveMemberVariable(BP, VarFName);
FBlueprintEditorUtils::MarkBlueprintAsStructurallyModified(BP);
bool bSaved = SaveBlueprintPackage(BP);
UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Removed variable '%s' from '%s' (saved: %s)"),
*VariableName, *BlueprintName, bSaved ? TEXT("true") : TEXT("false"));
TSharedRef<FJsonObject> Result = MakeShared<FJsonObject>();
Result->SetBoolField(TEXT("success"), true);
Result->SetStringField(TEXT("blueprint"), BlueprintName);
Result->SetStringField(TEXT("variableName"), VariableName);
Result->SetBoolField(TEXT("saved"), bSaved);
return JsonToString(Result);
}
// ============================================================
// HandleSetVariableMetadata — set variable properties (category, tooltip, replication, etc.)
// ============================================================
FString FBlueprintMCPServer::HandleSetVariableMetadata(const FString& Body)
{
TSharedPtr<FJsonObject> Json = ParseBodyJson(Body);
if (!Json.IsValid())
{
return MakeErrorJson(TEXT("Invalid JSON body"));
}
FString BlueprintName = Json->GetStringField(TEXT("blueprint"));
FString VariableName = Json->GetStringField(TEXT("variable"));
if (BlueprintName.IsEmpty() || VariableName.IsEmpty())
{
return MakeErrorJson(TEXT("Missing required fields: blueprint, variable"));
}
// Load Blueprint
FString LoadError;
UBlueprint* BP = LoadBlueprintByName(BlueprintName, LoadError);
if (!BP)
{
return MakeErrorJson(LoadError);
}
// Find the variable
FName VarFName(*VariableName);
FBPVariableDescription* VarDesc = nullptr;
for (FBPVariableDescription& Var : BP->NewVariables)
{
if (Var.VarName == VarFName)
{
VarDesc = &Var;
break;
}
}
if (!VarDesc)
{
TArray<TSharedPtr<FJsonValue>> AvailableVars;
for (const FBPVariableDescription& Var : BP->NewVariables)
{
AvailableVars.Add(MakeShared<FJsonValueString>(Var.VarName.ToString()));
}
TSharedRef<FJsonObject> ErrResult = MakeShared<FJsonObject>();
ErrResult->SetStringField(TEXT("error"), FString::Printf(
TEXT("Variable '%s' not found in Blueprint '%s'"), *VariableName, *BlueprintName));
ErrResult->SetArrayField(TEXT("availableVariables"), AvailableVars);
return JsonToString(ErrResult);
}
TArray<TSharedPtr<FJsonValue>> Changes;
// Category
if (Json->HasField(TEXT("category")))
{
FString OldCategory = VarDesc->Category.ToString();
FString NewCategory = Json->GetStringField(TEXT("category"));
VarDesc->Category = FText::FromString(NewCategory);
FBlueprintEditorUtils::SetBlueprintVariableCategory(BP, VarFName, nullptr, FText::FromString(NewCategory));
TSharedRef<FJsonObject> Change = MakeShared<FJsonObject>();
Change->SetStringField(TEXT("field"), TEXT("category"));
Change->SetStringField(TEXT("oldValue"), OldCategory);
Change->SetStringField(TEXT("newValue"), NewCategory);
Changes.Add(MakeShared<FJsonValueObject>(Change));
}
// Tooltip
if (Json->HasField(TEXT("tooltip")))
{
FString OldTooltip;
FBlueprintEditorUtils::GetBlueprintVariableMetaData(BP, VarFName, nullptr, TEXT("tooltip"), OldTooltip);
FString NewTooltip = Json->GetStringField(TEXT("tooltip"));
FBlueprintEditorUtils::SetBlueprintVariableMetaData(BP, VarFName, nullptr, TEXT("tooltip"), NewTooltip);
TSharedRef<FJsonObject> Change = MakeShared<FJsonObject>();
Change->SetStringField(TEXT("field"), TEXT("tooltip"));
Change->SetStringField(TEXT("oldValue"), OldTooltip);
Change->SetStringField(TEXT("newValue"), NewTooltip);
Changes.Add(MakeShared<FJsonValueObject>(Change));
}
// Replication
if (Json->HasField(TEXT("replication")))
{
FString ReplicationStr = Json->GetStringField(TEXT("replication"));
uint64 OldFlags = VarDesc->PropertyFlags;
if (ReplicationStr == TEXT("none"))
{
VarDesc->PropertyFlags &= ~CPF_Net;
VarDesc->PropertyFlags &= ~CPF_RepNotify;
VarDesc->RepNotifyFunc = NAME_None;
}
else if (ReplicationStr == TEXT("replicated"))
{
VarDesc->PropertyFlags |= CPF_Net;
VarDesc->PropertyFlags &= ~CPF_RepNotify;
VarDesc->RepNotifyFunc = NAME_None;
}
else if (ReplicationStr == TEXT("repNotify"))
{
VarDesc->PropertyFlags |= CPF_Net | CPF_RepNotify;
// Auto-generate RepNotify function name
VarDesc->RepNotifyFunc = FName(*FString::Printf(TEXT("OnRep_%s"), *VariableName));
}
else
{
return MakeErrorJson(FString::Printf(
TEXT("Invalid replication value '%s'. Valid: none, replicated, repNotify"), *ReplicationStr));
}
TSharedRef<FJsonObject> Change = MakeShared<FJsonObject>();
Change->SetStringField(TEXT("field"), TEXT("replication"));
Change->SetStringField(TEXT("newValue"), ReplicationStr);
Changes.Add(MakeShared<FJsonValueObject>(Change));
}
// ExposeOnSpawn
if (Json->HasField(TEXT("exposeOnSpawn")))
{
bool bOld = (VarDesc->PropertyFlags & CPF_ExposeOnSpawn) != 0;
bool bNew = Json->GetBoolField(TEXT("exposeOnSpawn"));
if (bNew)
VarDesc->PropertyFlags |= CPF_ExposeOnSpawn;
else
VarDesc->PropertyFlags &= ~CPF_ExposeOnSpawn;
TSharedRef<FJsonObject> Change = MakeShared<FJsonObject>();
Change->SetStringField(TEXT("field"), TEXT("exposeOnSpawn"));
Change->SetStringField(TEXT("oldValue"), bOld ? TEXT("true") : TEXT("false"));
Change->SetStringField(TEXT("newValue"), bNew ? TEXT("true") : TEXT("false"));
Changes.Add(MakeShared<FJsonValueObject>(Change));
}
// isPrivate
if (Json->HasField(TEXT("isPrivate")))
{
bool bOld = (VarDesc->PropertyFlags & CPF_DisableEditOnInstance) != 0;
bool bNew = Json->GetBoolField(TEXT("isPrivate"));
// In UE5, "private" for Blueprint variables is represented via metadata
FBlueprintEditorUtils::SetBlueprintVariableMetaData(BP, VarFName, nullptr,
TEXT("BlueprintPrivate"), bNew ? TEXT("true") : TEXT("false"));
TSharedRef<FJsonObject> Change = MakeShared<FJsonObject>();
Change->SetStringField(TEXT("field"), TEXT("isPrivate"));
Change->SetStringField(TEXT("oldValue"), bOld ? TEXT("true") : TEXT("false"));
Change->SetStringField(TEXT("newValue"), bNew ? TEXT("true") : TEXT("false"));
Changes.Add(MakeShared<FJsonValueObject>(Change));
}
// Editability (EditAnywhere, EditDefaultsOnly, EditInstanceOnly)
if (Json->HasField(TEXT("editability")))
{
FString Editability = Json->GetStringField(TEXT("editability"));
// Clear all edit flags first
VarDesc->PropertyFlags &= ~(CPF_Edit | CPF_DisableEditOnInstance | CPF_DisableEditOnTemplate);
if (Editability == TEXT("editAnywhere"))
{
VarDesc->PropertyFlags |= CPF_Edit;
}
else if (Editability == TEXT("editDefaultsOnly"))
{
VarDesc->PropertyFlags |= CPF_Edit | CPF_DisableEditOnInstance;
}
else if (Editability == TEXT("editInstanceOnly"))
{
VarDesc->PropertyFlags |= CPF_Edit | CPF_DisableEditOnTemplate;
}
else if (Editability == TEXT("none"))
{
// All edit flags already cleared
}
else
{
return MakeErrorJson(FString::Printf(
TEXT("Invalid editability value '%s'. Valid: editAnywhere, editDefaultsOnly, editInstanceOnly, none"),
*Editability));
}
TSharedRef<FJsonObject> Change = MakeShared<FJsonObject>();
Change->SetStringField(TEXT("field"), TEXT("editability"));
Change->SetStringField(TEXT("newValue"), Editability);
Changes.Add(MakeShared<FJsonValueObject>(Change));
}
if (Changes.Num() == 0)
{
return MakeErrorJson(TEXT("No metadata fields specified. Provide at least one of: category, tooltip, replication, exposeOnSpawn, isPrivate, editability"));
}
UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: SetVariableMetadata on '%s.%s' — %d field(s) changed"),
*BlueprintName, *VariableName, Changes.Num());
FBlueprintEditorUtils::MarkBlueprintAsStructurallyModified(BP);
bool bSaved = SaveBlueprintPackage(BP);
TSharedRef<FJsonObject> Result = MakeShared<FJsonObject>();
Result->SetBoolField(TEXT("success"), true);
Result->SetStringField(TEXT("blueprint"), BlueprintName);
Result->SetStringField(TEXT("variable"), VariableName);
Result->SetArrayField(TEXT("changes"), Changes);
Result->SetBoolField(TEXT("saved"), bSaved);
return JsonToString(Result);
}

View File

@@ -0,0 +1,4 @@
#include "BlueprintMCPModule.h"
#include "Modules/ModuleManager.h"
IMPLEMENT_MODULE(FBlueprintMCPModule, BlueprintMCP);

View File

@@ -0,0 +1,11 @@
#pragma once
#include "CoreMinimal.h"
#include "Modules/ModuleInterface.h"
class FBlueprintMCPModule : public IModuleInterface
{
public:
virtual void StartupModule() override {}
virtual void ShutdownModule() override {}
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,25 @@
#pragma once
#include "CoreMinimal.h"
#include "Commandlets/Commandlet.h"
#include "BlueprintMCPServer.h"
#include "BlueprintMCPCommandlet.generated.h"
/**
* Standalone commandlet that hosts the Blueprint MCP HTTP server.
* Delegates all logic to FBlueprintMCPServer and runs a manual engine tick loop.
*
* Usage: UnrealEditor-Cmd.exe Project.uproject -run=BlueprintMCP [-port=9847]
*/
UCLASS()
class UBlueprintMCPCommandlet : public UCommandlet
{
GENERATED_BODY()
public:
UBlueprintMCPCommandlet();
virtual int32 Main(const FString& Params) override;
private:
TUniquePtr<FBlueprintMCPServer> Server;
};

View File

@@ -0,0 +1,34 @@
#pragma once
#include "CoreMinimal.h"
#include "EditorSubsystem.h"
#include "Tickable.h"
#include "BlueprintMCPServer.h"
#include "BlueprintMCPEditorSubsystem.generated.h"
/**
* Editor subsystem that hosts the Blueprint MCP HTTP server inside the running
* UE5 editor. When active, the MCP TypeScript wrapper connects instantly
* (no commandlet spawn, no extra RAM).
*
* Requests are dequeued and processed on the editor's game thread via
* FTickableEditorObject::Tick().
*/
UCLASS()
class UBlueprintMCPEditorSubsystem : public UEditorSubsystem, public FTickableEditorObject
{
GENERATED_BODY()
public:
// UEditorSubsystem
virtual void Initialize(FSubsystemCollectionBase& Collection) override;
virtual void Deinitialize() override;
// FTickableEditorObject
virtual void Tick(float DeltaTime) override;
virtual bool IsTickable() const override;
virtual TStatId GetStatId() const override;
private:
TUniquePtr<FBlueprintMCPServer> Server;
};

View File

@@ -0,0 +1,325 @@
#pragma once
#include "CoreMinimal.h"
#include "Dom/JsonObject.h"
#include "AssetRegistry/AssetData.h"
#include "HttpResultCallback.h"
#include "EdGraph/EdGraphPin.h"
class UEdGraph;
class UEdGraphNode;
class UEdGraphPin;
class UBlueprint;
class UMaterial;
class UMaterialInstanceConstant;
class UMaterialFunction;
class UMaterialExpression;
// ----- Snapshot data structures -----
struct FPinConnectionRecord
{
FString SourceNodeGuid;
FString SourcePinName;
FString TargetNodeGuid;
FString TargetPinName;
};
struct FNodeRecord
{
FString NodeGuid;
FString NodeClass;
FString NodeTitle;
FString StructType; // for Break/Make nodes
};
struct FGraphSnapshotData
{
TArray<FNodeRecord> Nodes;
TArray<FPinConnectionRecord> Connections;
};
struct FGraphSnapshot
{
FString SnapshotId;
FString BlueprintName;
FString BlueprintPath;
FDateTime CreatedAt;
TMap<FString, FGraphSnapshotData> Graphs; // graphName -> data
};
/**
* FBlueprintMCPServer — plain C++ class (not a UCLASS) that owns all HTTP
* serving logic for the Blueprint MCP protocol.
*
* Both the standalone commandlet (UBlueprintMCPCommandlet) and the in-editor
* subsystem (UBlueprintMCPEditorSubsystem) delegate to an instance of this
* class. The only difference is *who ticks the engine*:
* - Commandlet: manual FTSTicker loop
* - Editor subsystem: UE editor tick via FTickableEditorObject
*/
class FBlueprintMCPServer
{
public:
/** Scan asset registry, bind HTTP routes, start listener on the given port.
* Set bEditorMode=true when hosted inside the UE5 editor (disables /api/shutdown). */
bool Start(int32 InPort, bool bEditorMode = false);
/** Stop the HTTP listener and clean up. */
void Stop();
/**
* Dequeue and handle ONE pending HTTP request on the calling (game) thread.
* Call this every tick from whichever host owns this server.
* Returns true if a request was processed.
*/
bool ProcessOneRequest();
/** Whether the HTTP server is currently listening. */
bool IsRunning() const { return bRunning; }
/** Port the server is listening on. */
int32 GetPort() const { return Port; }
/** Number of indexed Blueprint assets. */
int32 GetBlueprintCount() const { return AllBlueprintAssets.Num(); }
/** Number of indexed Map assets. */
int32 GetMapCount() const { return AllMapAssets.Num(); }
/** Number of indexed Material assets. */
int32 GetMaterialCount() const { return AllMaterialAssets.Num(); }
/** Number of indexed Material Instance assets. */
int32 GetMaterialInstanceCount() const { return AllMaterialInstanceAssets.Num(); }
private:
// ----- TMap-based request dispatch -----
using FRequestHandler = TFunction<FString(const TMap<FString, FString>&, const FString&)>;
TMap<FString, FRequestHandler> HandlerMap;
TSet<FString> MutationEndpoints;
void RegisterHandlers();
// ----- Queued request model -----
struct FPendingRequest
{
FString Endpoint;
TMap<FString, FString> QueryParams;
FString Body;
FHttpResultCallback OnComplete;
};
TQueue<TSharedPtr<FPendingRequest>> RequestQueue;
TArray<FAssetData> AllBlueprintAssets;
TArray<FAssetData> AllMapAssets;
TArray<FAssetData> AllMaterialAssets;
TArray<FAssetData> AllMaterialInstanceAssets;
TArray<FAssetData> AllMaterialFunctionAssets;
int32 Port = 9847;
bool bRunning = false;
bool bIsEditor = false;
// ----- Asset registry rescan -----
FString HandleRescan();
// ----- Request handlers (read-only) -----
FString HandleList(const TMap<FString, FString>& Params);
FString HandleGetBlueprint(const TMap<FString, FString>& Params);
FString HandleGetGraph(const TMap<FString, FString>& Params);
FString HandleSearch(const TMap<FString, FString>& Params);
FString HandleFindReferences(const TMap<FString, FString>& Params);
FString HandleSearchByType(const TMap<FString, FString>& Params);
// ----- Request handlers (write) -----
FString HandleReplaceFunctionCalls(const FString& Body);
FString HandleChangeVariableType(const FString& Body);
FString HandleChangeFunctionParamType(const FString& Body);
FString HandleRemoveFunctionParameter(const FString& Body);
FString HandleDeleteAsset(const FString& Body);
FString HandleDeleteNode(const FString& Body);
FString HandleDuplicateNodes(const FString& Body);
FString HandleAddNode(const FString& Body);
FString HandleRenameAsset(const FString& Body);
// ----- Validation (read-only, no save) -----
FString HandleValidateBlueprint(const FString& Body);
FString HandleValidateAllBlueprints(const FString& Body);
// ----- Pin manipulation (write) -----
FString HandleConnectPins(const FString& Body);
FString HandleDisconnectPin(const FString& Body);
FString HandleRefreshAllNodes(const FString& Body);
FString HandleSetPinDefault(const FString& Body);
FString HandleMoveNode(const FString& Body);
FString HandleGetNodeComment(const FString& Body);
FString HandleSetNodeComment(const FString& Body);
// ----- Pin introspection (read-only) -----
FString HandleGetPinInfo(const FString& Body);
FString HandleCheckPinCompatibility(const FString& Body);
// ----- Class/function discovery (read-only) -----
FString HandleListClasses(const FString& Body);
FString HandleListFunctions(const FString& Body);
FString HandleListProperties(const FString& Body);
// ----- Struct node manipulation (write) -----
FString HandleChangeStructNodeType(const FString& Body);
// ----- Reparent -----
FString HandleReparentBlueprint(const FString& Body);
// ----- Create -----
FString HandleCreateBlueprint(const FString& Body);
FString HandleCreateGraph(const FString& Body);
// ----- User-defined types -----
FString HandleCreateStruct(const FString& Body);
FString HandleCreateEnum(const FString& Body);
FString HandleAddStructProperty(const FString& Body);
FString HandleRemoveStructProperty(const FString& Body);
// ----- Graph manipulation -----
FString HandleDeleteGraph(const FString& Body);
FString HandleRenameGraph(const FString& Body);
// ----- Variables -----
FString HandleAddVariable(const FString& Body);
FString HandleRemoveVariable(const FString& Body);
FString HandleSetVariableMetadata(const FString& Body);
// ----- Interfaces -----
FString HandleAddInterface(const FString& Body);
FString HandleRemoveInterface(const FString& Body);
FString HandleListInterfaces(const FString& Body);
// ----- Event Dispatchers -----
FString HandleAddEventDispatcher(const FString& Body);
FString HandleListEventDispatchers(const FString& Body);
// ----- Function Parameters -----
FString HandleAddFunctionParameter(const FString& Body);
// ----- Components -----
FString HandleAddComponent(const FString& Body);
FString HandleRemoveComponent(const FString& Body);
FString HandleListComponents(const FString& Body);
// ----- Property defaults -----
FString HandleSetBlueprintDefault(const FString& Body);
// ----- Generic node spawning via action database -----
FString HandleSearchNodeActions(const FString& Body);
FString HandleSpawnNode(const FString& Body);
// ----- Diagnostic -----
FString HandleTestSave(const TMap<FString, FString>& Params);
// ----- Snapshot / Safety tools (write) -----
FString HandleSnapshotGraph(const FString& Body);
FString HandleDiffGraph(const FString& Body);
FString HandleRestoreGraph(const FString& Body);
FString HandleFindDisconnectedPins(const FString& Body);
FString HandleAnalyzeRebuildImpact(const FString& Body);
// ----- Cross-Blueprint comparison (read-only) -----
FString HandleDiffBlueprints(const FString& Body);
// ----- Material read-only handlers (Phase 1) -----
FString HandleListMaterials(const TMap<FString, FString>& Params);
FString HandleGetMaterial(const TMap<FString, FString>& Params);
FString HandleGetMaterialGraph(const TMap<FString, FString>& Params);
FString HandleDescribeMaterial(const FString& Body);
FString HandleSearchMaterials(const TMap<FString, FString>& Params);
FString HandleFindMaterialReferences(const FString& Body);
// ----- Material mutation handlers (Phase 2) -----
FString HandleCreateMaterial(const FString& Body);
FString HandleSetMaterialProperty(const FString& Body);
FString HandleAddMaterialExpression(const FString& Body);
FString HandleDeleteMaterialExpression(const FString& Body);
FString HandleConnectMaterialPins(const FString& Body);
FString HandleDisconnectMaterialPin(const FString& Body);
FString HandleSetExpressionValue(const FString& Body);
FString HandleMoveMaterialExpression(const FString& Body);
// ----- Material instance handlers (Phase 3) -----
FString HandleCreateMaterialInstance(const FString& Body);
FString HandleSetMaterialInstanceParameter(const FString& Body);
FString HandleGetMaterialInstanceParameters(const TMap<FString, FString>& Params);
FString HandleReparentMaterialInstance(const FString& Body);
// ----- Material function handlers (Phase 4) -----
FString HandleListMaterialFunctions(const TMap<FString, FString>& Params);
FString HandleGetMaterialFunction(const TMap<FString, FString>& Params);
FString HandleCreateMaterialFunction(const FString& Body);
// ----- Material validation -----
FString HandleValidateMaterial(const FString& Body);
// ----- Material snapshot/diff/restore (Phase 5) -----
FString HandleSnapshotMaterialGraph(const FString& Body);
FString HandleDiffMaterialGraph(const FString& Body);
FString HandleRestoreMaterialGraph(const FString& Body);
// ----- Animation Blueprint handlers -----
FString HandleCreateAnimBlueprint(const FString& Body);
FString HandleAddAnimState(const FString& Body);
FString HandleRemoveAnimState(const FString& Body);
FString HandleAddAnimTransition(const FString& Body);
FString HandleSetTransitionRule(const FString& Body);
FString HandleAddAnimNode(const FString& Body);
FString HandleAddStateMachine(const FString& Body);
FString HandleSetStateAnimation(const FString& Body);
FString HandleListAnimSlots(const FString& Body);
FString HandleListSyncGroups(const FString& Body);
FString HandleCreateBlendSpace(const FString& Body);
FString HandleSetBlendSpaceSamples(const FString& Body);
FString HandleSetStateBlendSpace(const FString& Body);
// ----- Serialization -----
TSharedRef<FJsonObject> SerializeBlueprint(UBlueprint* BP);
TSharedPtr<FJsonObject> SerializeGraph(UEdGraph* Graph);
TSharedPtr<FJsonObject> SerializeNode(UEdGraphNode* Node);
TSharedPtr<FJsonObject> SerializePin(UEdGraphPin* Pin);
TSharedPtr<FJsonObject> SerializeMaterialExpression(UMaterialExpression* Expression);
FString JsonToString(TSharedRef<FJsonObject> JsonObj);
// ----- Helpers -----
FAssetData* FindAnyAsset(const FString& NameOrPath);
FAssetData* FindBlueprintAsset(const FString& NameOrPath);
FAssetData* FindMapAsset(const FString& NameOrPath);
UBlueprint* LoadBlueprintByName(const FString& NameOrPath, FString& OutError);
UEdGraphNode* FindNodeByGuid(UBlueprint* BP, const FString& GuidString, UEdGraph** OutGraph = nullptr);
TSharedPtr<FJsonObject> ParseBodyJson(const FString& Body);
FString MakeErrorJson(const FString& Message);
bool SaveBlueprintPackage(UBlueprint* BP);
static FString UrlDecode(const FString& EncodedString);
// ----- Material helpers -----
/** Ensure that Material->MaterialGraph exists (creates it on demand for commandlet mode). */
void EnsureMaterialGraph(UMaterial* Material);
FAssetData* FindMaterialAsset(const FString& NameOrPath);
UMaterial* LoadMaterialByName(const FString& NameOrPath, FString& OutError);
FAssetData* FindMaterialInstanceAsset(const FString& NameOrPath);
UMaterialInstanceConstant* LoadMaterialInstanceByName(const FString& NameOrPath, FString& OutError);
FAssetData* FindMaterialFunctionAsset(const FString& NameOrPath);
UMaterialFunction* LoadMaterialFunctionByName(const FString& NameOrPath, FString& OutError);
bool SaveMaterialPackage(UMaterial* Material);
bool SaveGenericPackage(UObject* Asset);
// ----- Type resolution -----
bool ResolveTypeFromString(const FString& TypeName, FEdGraphPinType& OutPinType, FString& OutError);
static UClass* FindClassByName(const FString& ClassName);
// ----- Snapshot storage -----
TMap<FString, FGraphSnapshot> Snapshots;
TMap<FString, FGraphSnapshot> MaterialSnapshots;
static const int32 MaxSnapshots = 50;
// Snapshot helpers
FString GenerateSnapshotId(const FString& BlueprintName);
FGraphSnapshotData CaptureGraphSnapshot(UEdGraph* Graph);
void PruneOldSnapshots();
bool SaveSnapshotToDisk(const FString& SnapshotId, const FGraphSnapshot& Snapshot);
bool LoadSnapshotFromDisk(const FString& SnapshotId, FGraphSnapshot& OutSnapshot);
};