From 862eb697cb577fe22131b6188031b45347f327b2 Mon Sep 17 00:00:00 2001 From: jyelon Date: Sat, 7 Mar 2026 04:14:22 -0500 Subject: [PATCH] All MCP handlers converted to new format --- .../BlueprintMCP/Private/MCPHandlers.cpp | 11 + .../Private/MCPHandlers_Components.cpp | 345 --- .../Private/MCPHandlers_Components.h | 376 ++++ .../Private/MCPHandlers_Discovery.cpp | 508 ----- .../Private/MCPHandlers_Discovery.h | 595 +++++ .../Private/MCPHandlers_Dispatchers.cpp | 227 -- .../Private/MCPHandlers_Dispatchers.h | 243 ++ .../Private/MCPHandlers_Graphs.cpp | 570 ----- .../BlueprintMCP/Private/MCPHandlers_Graphs.h | 625 ++++++ .../Private/MCPHandlers_MaterialInstance.cpp | 695 ------ .../Private/MCPHandlers_MaterialInstance.h | 733 +++++++ .../Private/MCPHandlers_MaterialMutation.cpp | 1954 ----------------- .../Private/MCPHandlers_MaterialMutation.h | 1632 ++++++++++++++ .../Private/MCPHandlers_MaterialRead.cpp | 927 -------- .../Private/MCPHandlers_MaterialRead.h | 1020 +++++++++ .../Private/MCPHandlers_Mutation.h | 122 +- .../Private/MCPHandlers_Params.cpp | 567 ----- .../BlueprintMCP/Private/MCPHandlers_Params.h | 606 +++++ .../Private/MCPHandlers_PinMutation.h | 56 +- .../BlueprintMCP/Private/MCPHandlers_Read.cpp | 630 ------ .../BlueprintMCP/Private/MCPHandlers_Read.h | 729 ++++++ .../Private/MCPHandlers_Snapshot.cpp | 1348 ------------ .../Private/MCPHandlers_StateMachine.cpp | 540 ----- .../Private/MCPHandlers_StateMachine.h | 674 ++++++ .../Private/MCPHandlers_Variables.cpp | 583 ----- .../Private/MCPHandlers_Variables.h | 625 ++++++ .../Source/BlueprintMCP/Private/MCPServer.cpp | 105 - .../Source/BlueprintMCP/Public/MCPServer.h | 125 +- .../Source/BlueprintMCP/Public/MCPUtils.h | 33 - refactor.md | 194 ++ 30 files changed, 8154 insertions(+), 9244 deletions(-) delete mode 100644 Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Components.cpp create mode 100644 Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Components.h delete mode 100644 Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Discovery.cpp create mode 100644 Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Discovery.h delete mode 100644 Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Dispatchers.cpp create mode 100644 Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Dispatchers.h delete mode 100644 Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Graphs.cpp create mode 100644 Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Graphs.h delete mode 100644 Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_MaterialInstance.cpp create mode 100644 Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_MaterialInstance.h delete mode 100644 Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_MaterialMutation.cpp create mode 100644 Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_MaterialMutation.h delete mode 100644 Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_MaterialRead.cpp create mode 100644 Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_MaterialRead.h delete mode 100644 Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Params.cpp create mode 100644 Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Params.h delete mode 100644 Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Read.cpp create mode 100644 Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Read.h delete mode 100644 Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Snapshot.cpp delete mode 100644 Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_StateMachine.cpp create mode 100644 Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_StateMachine.h delete mode 100644 Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Variables.cpp create mode 100644 Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Variables.h create mode 100644 refactor.md diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers.cpp b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers.cpp index c6a4101a..1571e9d4 100644 --- a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers.cpp +++ b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers.cpp @@ -6,3 +6,14 @@ #include "MCPHandlers_Validation.h" #include "MCPHandlers_UserTypes.h" #include "MCPHandlers_AnimMutation.h" +#include "MCPHandlers_Read.h" +#include "MCPHandlers_Discovery.h" +#include "MCPHandlers_Graphs.h" +#include "MCPHandlers_Variables.h" +#include "MCPHandlers_Params.h" +#include "MCPHandlers_Dispatchers.h" +#include "MCPHandlers_Components.h" +#include "MCPHandlers_MaterialRead.h" +#include "MCPHandlers_MaterialMutation.h" +#include "MCPHandlers_MaterialInstance.h" +#include "MCPHandlers_StateMachine.h" diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Components.cpp b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Components.cpp deleted file mode 100644 index 64039ad1..00000000 --- a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Components.cpp +++ /dev/null @@ -1,345 +0,0 @@ -#include "MCPAssetFinder.h" -#include "MCPServer.h" -#include "MCPUtils.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 -// ============================================================ - -void FBlueprintMCPServer::HandleListComponents(const FJsonObject* Json, FJsonObject* Result) -{ - FString BlueprintName = Json->GetStringField(TEXT("blueprint")); - if (BlueprintName.IsEmpty()) - { - return MCPUtils::MakeErrorJson(Result, TEXT("Missing required field: blueprint")); - } - - FString LoadError; - UBlueprint* BP = UMCPAssetFinder::LoadBlueprintOrLevelBlueprint(BlueprintName, LoadError); - if (!BP) - { - return MCPUtils::MakeErrorJson(Result, LoadError); - } - - USimpleConstructionScript* SCS = BP->SimpleConstructionScript; - if (!SCS) - { - return MCPUtils::MakeErrorJson(Result, FString::Printf( - TEXT("Blueprint '%s' does not have a SimpleConstructionScript (not an Actor Blueprint)"), - *BlueprintName)); - } - - const TArray& AllNodes = SCS->GetAllNodes(); - - TArray> ComponentsArr; - for (USCS_Node* Node : AllNodes) - { - if (!Node) - { - continue; - } - - TSharedRef CompObj = MakeShared(); - 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& 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(CompObj)); - } - - Result->SetStringField(TEXT("blueprint"), BlueprintName); - Result->SetNumberField(TEXT("count"), ComponentsArr.Num()); - Result->SetArrayField(TEXT("components"), ComponentsArr); -} - -// ============================================================ -// HandleAddComponent — add a component to a Blueprint's SCS -// ============================================================ - -void FBlueprintMCPServer::HandleAddComponent(const FJsonObject* Json, FJsonObject* Result) -{ - 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 MCPUtils::MakeErrorJson(Result, TEXT("Missing required fields: blueprint, componentClass, name")); - } - - FString ParentComponentName; - if (Json->HasField(TEXT("parentComponent"))) - { - ParentComponentName = Json->GetStringField(TEXT("parentComponent")); - } - - FString LoadError; - UBlueprint* BP = UMCPAssetFinder::LoadBlueprintOrLevelBlueprint(BlueprintName, LoadError); - if (!BP) - { - return MCPUtils::MakeErrorJson(Result, LoadError); - } - - USimpleConstructionScript* SCS = BP->SimpleConstructionScript; - if (!SCS) - { - return MCPUtils::MakeErrorJson(Result, FString::Printf( - TEXT("Blueprint '%s' does not have a SimpleConstructionScript (not an Actor Blueprint)"), - *BlueprintName)); - } - - // Check for duplicate component names - const TArray& ExistingNodes = SCS->GetAllNodes(); - for (USCS_Node* Existing : ExistingNodes) - { - if (Existing && Existing->GetVariableName().ToString().Equals(ComponentName, ESearchCase::IgnoreCase)) - { - return MCPUtils::MakeErrorJson(Result, 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 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 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 MCPUtils::MakeErrorJson(Result, 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 MCPUtils::MakeErrorJson(Result, 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 MCPUtils::MakeErrorJson(Result, 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 = MCPUtils::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")); - - 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); -} - -// ============================================================ -// HandleRemoveComponent — remove a component from a Blueprint's SCS -// ============================================================ - -void FBlueprintMCPServer::HandleRemoveComponent(const FJsonObject* Json, FJsonObject* Result) -{ - FString BlueprintName = Json->GetStringField(TEXT("blueprint")); - FString ComponentName = Json->GetStringField(TEXT("name")); - - if (BlueprintName.IsEmpty() || ComponentName.IsEmpty()) - { - return MCPUtils::MakeErrorJson(Result, TEXT("Missing required fields: blueprint, name")); - } - - FString LoadError; - UBlueprint* BP = UMCPAssetFinder::LoadBlueprintOrLevelBlueprint(BlueprintName, LoadError); - if (!BP) - { - return MCPUtils::MakeErrorJson(Result, LoadError); - } - - USimpleConstructionScript* SCS = BP->SimpleConstructionScript; - if (!SCS) - { - return MCPUtils::MakeErrorJson(Result, 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& 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> CompList; - for (USCS_Node* Node : AllNodes) - { - if (Node) - { - CompList.Add(MakeShared(Node->GetVariableName().ToString())); - } - } - - MCPUtils::MakeErrorJson(Result, FString::Printf( - TEXT("Component '%s' not found in Blueprint '%s'"), - *ComponentName, *BlueprintName)); - Result->SetArrayField(TEXT("existingComponents"), CompList); - return; - } - - // Prevent removing the root scene component if it has children - const TArray& RootNodes = SCS->GetRootNodes(); - if (RootNodes.Contains(NodeToRemove) && NodeToRemove->GetChildNodes().Num() > 0) - { - return MCPUtils::MakeErrorJson(Result, 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 = MCPUtils::SaveBlueprintPackage(BP); - - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Removed component '%s' from '%s' (saved: %s)"), - *ComponentName, *BlueprintName, bSaved ? TEXT("true") : TEXT("false")); - - Result->SetBoolField(TEXT("success"), true); - Result->SetStringField(TEXT("blueprint"), BlueprintName); - Result->SetStringField(TEXT("name"), ComponentName); - Result->SetBoolField(TEXT("saved"), bSaved); -} diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Components.h b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Components.h new file mode 100644 index 00000000..64b599fb --- /dev/null +++ b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Components.h @@ -0,0 +1,376 @@ +#pragma once + +#include "CoreMinimal.h" +#include "MCPHandler.h" +#include "MCPAssetFinder.h" +#include "MCPUtils.h" +#include "Engine/Blueprint.h" +#include "Engine/SimpleConstructionScript.h" +#include "Engine/SCS_Node.h" +#include "Components/ActorComponent.h" +#include "Kismet2/BlueprintEditorUtils.h" +#include "UObject/UObjectIterator.h" +#include "MCPHandlers_Components.generated.h" + +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + +UCLASS(meta=(ToolName="list_blueprint_components")) +class UMCPHandler_ListBlueprintComponents : public UObject, public IMCPHandler +{ + GENERATED_BODY() + +public: + UPROPERTY(meta=(Description="Blueprint name or package path")) + FString Blueprint; + + virtual FString GetDescription() const override + { + return TEXT("List all components in a Blueprint's SimpleConstructionScript, " + "including hierarchy and scene root information."); + } + + virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override + { + FString LoadError; + UBlueprint* BP = UMCPAssetFinder::LoadBlueprintOrLevelBlueprint(Blueprint, LoadError); + if (!BP) + { + return MCPUtils::MakeErrorJson(Result, LoadError); + } + + USimpleConstructionScript* SCS = BP->SimpleConstructionScript; + if (!SCS) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf( + TEXT("Blueprint '%s' does not have a SimpleConstructionScript (not an Actor Blueprint)"), + *Blueprint)); + } + + const TArray& AllNodes = SCS->GetAllNodes(); + + TArray> ComponentsArr; + for (USCS_Node* Node : AllNodes) + { + if (!Node) + { + continue; + } + + TSharedRef CompObj = MakeShared(); + 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& 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(CompObj)); + } + + Result->SetStringField(TEXT("blueprint"), Blueprint); + Result->SetNumberField(TEXT("count"), ComponentsArr.Num()); + Result->SetArrayField(TEXT("components"), ComponentsArr); + } +}; + +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + +UCLASS(meta=(ToolName="add_blueprint_component")) +class UMCPHandler_AddBlueprintComponent : public UObject, public IMCPHandler +{ + GENERATED_BODY() + +public: + UPROPERTY(meta=(Description="Blueprint name or package path")) + FString Blueprint; + + UPROPERTY(meta=(Description="Component class name (e.g. StaticMeshComponent, SceneComponent)")) + FString ComponentClass; + + UPROPERTY(meta=(Description="Component name for the new component")) + FString Component; + + UPROPERTY(meta=(Optional, Description="Name of the parent component to attach to")) + FString ParentComponent; + + virtual FString GetDescription() const override + { + return TEXT("Add a component to a Blueprint's SimpleConstructionScript. " + "Optionally attach it to an existing parent component."); + } + + virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override + { + FString LoadError; + UBlueprint* BP = UMCPAssetFinder::LoadBlueprintOrLevelBlueprint(Blueprint, LoadError); + if (!BP) + { + return MCPUtils::MakeErrorJson(Result, LoadError); + } + + USimpleConstructionScript* SCS = BP->SimpleConstructionScript; + if (!SCS) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf( + TEXT("Blueprint '%s' does not have a SimpleConstructionScript (not an Actor Blueprint)"), + *Blueprint)); + } + + // Check for duplicate component names + const TArray& ExistingNodes = SCS->GetAllNodes(); + for (USCS_Node* Existing : ExistingNodes) + { + if (Existing && Existing->GetVariableName().ToString().Equals(Component, ESearchCase::IgnoreCase)) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf( + TEXT("A component named '%s' already exists in Blueprint '%s'"), + *Component, *Blueprint)); + } + } + + // Resolve the component class by name + // Try multiple name variants: exact name, with U prefix, without U prefix + UClass* ComponentClassObj = nullptr; + + TArray NamesToTry; + NamesToTry.Add(ComponentClass); + if (!ComponentClass.StartsWith(TEXT("U"))) + { + NamesToTry.Add(FString::Printf(TEXT("U%s"), *ComponentClass)); + } + else + { + // Also try without U prefix + NamesToTry.Add(ComponentClass.Mid(1)); + } + + for (TObjectIterator It; It; ++It) + { + if (!It->IsChildOf(UActorComponent::StaticClass())) + { + continue; + } + + FString ClassName = It->GetName(); + for (const FString& NameToTry : NamesToTry) + { + if (ClassName.Equals(NameToTry, ESearchCase::IgnoreCase)) + { + ComponentClassObj = *It; + break; + } + } + + if (ComponentClassObj) + { + break; + } + } + + if (!ComponentClassObj) + { + return MCPUtils::MakeErrorJson(Result, 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"), + *ComponentClass)); + } + + // If parent component specified, find its SCS node + USCS_Node* ParentSCSNode = nullptr; + if (!ParentComponent.IsEmpty()) + { + for (USCS_Node* Node : ExistingNodes) + { + if (Node && Node->GetVariableName().ToString().Equals(ParentComponent, ESearchCase::IgnoreCase)) + { + ParentSCSNode = Node; + break; + } + } + + if (!ParentSCSNode) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf( + TEXT("Parent component '%s' not found in Blueprint '%s'"), + *ParentComponent, *Blueprint)); + } + } + + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Adding component '%s' (%s) to Blueprint '%s'"), + *Component, *ComponentClassObj->GetName(), *Blueprint); + + // Create the SCS node + USCS_Node* NewNode = SCS->CreateNode(ComponentClassObj, FName(*Component)); + if (!NewNode) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf( + TEXT("Failed to create SCS node for component '%s' with class '%s'"), + *Component, *ComponentClassObj->GetName())); + } + + // Add to the hierarchy + if (ParentSCSNode) + { + ParentSCSNode->AddChildNode(NewNode); + } + else + { + SCS->AddNode(NewNode); + } + + FBlueprintEditorUtils::MarkBlueprintAsStructurallyModified(BP); + bool bSaved = MCPUtils::SaveBlueprintPackage(BP); + + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Added component '%s' (%s) to '%s' (parent: %s, saved: %s)"), + *Component, *ComponentClassObj->GetName(), *Blueprint, + ParentSCSNode ? *ParentComponent : TEXT("(root)"), + bSaved ? TEXT("true") : TEXT("false")); + + Result->SetBoolField(TEXT("success"), true); + Result->SetStringField(TEXT("blueprint"), Blueprint); + Result->SetStringField(TEXT("component"), NewNode->GetVariableName().ToString()); + Result->SetStringField(TEXT("componentClass"), ComponentClassObj->GetName()); + if (ParentSCSNode) + { + Result->SetStringField(TEXT("parentComponent"), ParentSCSNode->GetVariableName().ToString()); + } + Result->SetBoolField(TEXT("saved"), bSaved); + } +}; + +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + +UCLASS(meta=(ToolName="remove_blueprint_component")) +class UMCPHandler_RemoveBlueprintComponent : public UObject, public IMCPHandler +{ + GENERATED_BODY() + +public: + UPROPERTY(meta=(Description="Blueprint name or package path")) + FString Blueprint; + + UPROPERTY(meta=(Description="Component to remove")) + FString Component; + + virtual FString GetDescription() const override + { + return TEXT("Remove a component from a Blueprint's SimpleConstructionScript."); + } + + virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override + { + FString LoadError; + UBlueprint* BP = UMCPAssetFinder::LoadBlueprintOrLevelBlueprint(Blueprint, LoadError); + if (!BP) + { + return MCPUtils::MakeErrorJson(Result, LoadError); + } + + USimpleConstructionScript* SCS = BP->SimpleConstructionScript; + if (!SCS) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf( + TEXT("Blueprint '%s' does not have a SimpleConstructionScript (not an Actor Blueprint)"), + *Blueprint)); + } + + // Find the node to remove + USCS_Node* NodeToRemove = nullptr; + const TArray& AllNodes = SCS->GetAllNodes(); + for (USCS_Node* Node : AllNodes) + { + if (Node && Node->GetVariableName().ToString().Equals(Component, ESearchCase::IgnoreCase)) + { + NodeToRemove = Node; + break; + } + } + + if (!NodeToRemove) + { + // Build list of component names for the error message + TArray> CompList; + for (USCS_Node* Node : AllNodes) + { + if (Node) + { + CompList.Add(MakeShared(Node->GetVariableName().ToString())); + } + } + + MCPUtils::MakeErrorJson(Result, FString::Printf( + TEXT("Component '%s' not found in Blueprint '%s'"), + *Component, *Blueprint)); + Result->SetArrayField(TEXT("existingComponents"), CompList); + return; + } + + // Prevent removing the root scene component if it has children + const TArray& RootNodes = SCS->GetRootNodes(); + if (RootNodes.Contains(NodeToRemove) && NodeToRemove->GetChildNodes().Num() > 0) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf( + TEXT("Cannot remove component '%s' because it is a root component with %d child(ren). " + "Remove or re-parent the children first."), + *Component, NodeToRemove->GetChildNodes().Num())); + } + + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Removing component '%s' from Blueprint '%s'"), + *Component, *Blueprint); + + // 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 = MCPUtils::SaveBlueprintPackage(BP); + + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Removed component '%s' from '%s' (saved: %s)"), + *Component, *Blueprint, bSaved ? TEXT("true") : TEXT("false")); + + Result->SetBoolField(TEXT("success"), true); + Result->SetStringField(TEXT("blueprint"), Blueprint); + Result->SetStringField(TEXT("component"), Component); + Result->SetBoolField(TEXT("saved"), bSaved); + } +}; diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Discovery.cpp b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Discovery.cpp deleted file mode 100644 index f593d6d4..00000000 --- a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Discovery.cpp +++ /dev/null @@ -1,508 +0,0 @@ -#include "MCPAssetFinder.h" -#include "MCPServer.h" -#include "MCPUtils.h" -#include "Engine/Blueprint.h" -#include "EdGraph/EdGraph.h" -#include "EdGraph/EdGraphNode.h" -#include "EdGraph/EdGraphPin.h" -#include "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 -// ============================================================ - -void FBlueprintMCPServer::HandleGetPinInfo(const FJsonObject* Json, FJsonObject* Result) -{ - 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 MCPUtils::MakeErrorJson(Result, TEXT("Missing required fields: blueprint, nodeId, pinName")); - } - - FString LoadError; - UBlueprint* BP = UMCPAssetFinder::LoadBlueprintOrLevelBlueprint(BlueprintName, LoadError); - if (!BP) - { - return MCPUtils::MakeErrorJson(Result, LoadError); - } - - UEdGraph* Graph = nullptr; - UEdGraphNode* Node = MCPUtils::FindNodeByGuid(BP, NodeId, &Graph); - if (!Node) - { - return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Node '%s' not found"), *NodeId)); - } - - UEdGraphPin* Pin = Node->FindPin(FName(*PinName)); - if (!Pin) - { - // List available pins - TArray> AvailPins; - for (UEdGraphPin* P : Node->Pins) - { - if (P) - { - TSharedRef PinObj = MakeShared(); - 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(PinObj)); - } - } - MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Pin '%s' not found on node '%s'"), *PinName, *NodeId)); - Result->SetArrayField(TEXT("availablePins"), AvailPins); - return; - } - - 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> Conns; - for (UEdGraphPin* Linked : Pin->LinkedTo) - { - if (!Linked || !Linked->GetOwningNode()) continue; - TSharedRef CJ = MakeShared(); - 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(CJ)); - } - Result->SetArrayField(TEXT("connectedTo"), Conns); - } -} - -// ============================================================ -// HandleCheckPinCompatibility — pre-flight check for connect_pins -// ============================================================ - -void FBlueprintMCPServer::HandleCheckPinCompatibility(const FJsonObject* Json, FJsonObject* Result) -{ - 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 MCPUtils::MakeErrorJson(Result, TEXT("Missing required fields: blueprint, sourceNodeId, sourcePinName, targetNodeId, targetPinName")); - } - - FString LoadError; - UBlueprint* BP = UMCPAssetFinder::LoadBlueprintOrLevelBlueprint(BlueprintName, LoadError); - if (!BP) - { - return MCPUtils::MakeErrorJson(Result, LoadError); - } - - UEdGraph* SourceGraph = nullptr; - UEdGraphNode* SourceNode = MCPUtils::FindNodeByGuid(BP, SourceNodeId, &SourceGraph); - if (!SourceNode) - { - return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Source node '%s' not found"), *SourceNodeId)); - } - - UEdGraphNode* TargetNode = MCPUtils::FindNodeByGuid(BP, TargetNodeId); - if (!TargetNode) - { - return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Target node '%s' not found"), *TargetNodeId)); - } - - UEdGraphPin* SourcePin = SourceNode->FindPin(FName(*SourcePinName)); - if (!SourcePin) - { - return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Source pin '%s' not found on node '%s'"), *SourcePinName, *SourceNodeId)); - } - - UEdGraphPin* TargetPin = TargetNode->FindPin(FName(*TargetPinName)); - if (!TargetPin) - { - return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Target pin '%s' not found on node '%s'"), *TargetPinName, *TargetNodeId)); - } - - const UEdGraphSchema* Schema = SourceGraph ? SourceGraph->GetSchema() : nullptr; - if (!Schema) - { - return MCPUtils::MakeErrorJson(Result, TEXT("Graph schema not found")); - } - - // Check compatibility using the schema - const FPinConnectionResponse Response = Schema->CanCreateConnection(SourcePin, TargetPin); - - 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()); -} - -// ============================================================ -// HandleListClasses — discover available UClasses -// ============================================================ - -void FBlueprintMCPServer::HandleListClasses(const FJsonObject* Json, FJsonObject* Result) -{ - 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 It; It; ++It) - { - if (It->GetName() == ParentClassName || It->GetName() == ParentClassName + TEXT("_C")) - { - ParentClass = *It; - break; - } - } - if (!ParentClass) - { - return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Parent class '%s' not found"), *ParentClassName)); - } - } - - TArray> ClassList; - int32 TotalMatched = 0; - - for (TObjectIterator 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 ClassObj = MakeShared(); - 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> Flags; - if (Class->HasAnyClassFlags(CLASS_Abstract)) Flags.Add(MakeShared(TEXT("Abstract"))); - if (Class->HasAnyClassFlags(CLASS_Interface)) Flags.Add(MakeShared(TEXT("Interface"))); - if (Class->HasAnyClassFlags(CLASS_MinimalAPI)) Flags.Add(MakeShared(TEXT("MinimalAPI"))); - if (Flags.Num() > 0) - { - ClassObj->SetArrayField(TEXT("flags"), Flags); - } - - ClassList.Add(MakeShared(ClassObj)); - } - - 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); -} - -// ============================================================ -// HandleListFunctions — list Blueprint-callable functions on a class -// ============================================================ - -void FBlueprintMCPServer::HandleListFunctions(const FJsonObject* Json, FJsonObject* Result) -{ - FString ClassName = Json->GetStringField(TEXT("className")); - FString Filter = Json->GetStringField(TEXT("filter")); - - if (ClassName.IsEmpty()) - { - return MCPUtils::MakeErrorJson(Result, TEXT("Missing required field: className")); - } - - // Find the class - UClass* FoundClass = nullptr; - for (TObjectIterator It; It; ++It) - { - if (It->GetName() == ClassName || It->GetName() == ClassName + TEXT("_C")) - { - FoundClass = *It; - break; - } - } - if (!FoundClass) - { - return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Class '%s' not found"), *ClassName)); - } - - TArray> FuncList; - - for (TFieldIterator 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 FuncObj = MakeShared(); - 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> Params; - FString ReturnType; - for (TFieldIterator 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 ParamObj = MakeShared(); - 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(ParamObj)); - } - } - FuncObj->SetArrayField(TEXT("parameters"), Params); - if (!ReturnType.IsEmpty()) - { - FuncObj->SetStringField(TEXT("returnType"), ReturnType); - } - - FuncList.Add(MakeShared(FuncObj)); - } - - Result->SetBoolField(TEXT("success"), true); - Result->SetStringField(TEXT("className"), FoundClass->GetName()); - Result->SetNumberField(TEXT("count"), FuncList.Num()); - Result->SetArrayField(TEXT("functions"), FuncList); -} - -// ============================================================ -// HandleListProperties — list properties on a class -// ============================================================ - -void FBlueprintMCPServer::HandleListProperties(const FJsonObject* Json, FJsonObject* Result) -{ - FString ClassName = Json->GetStringField(TEXT("className")); - FString Filter = Json->GetStringField(TEXT("filter")); - - if (ClassName.IsEmpty()) - { - return MCPUtils::MakeErrorJson(Result, TEXT("Missing required field: className")); - } - - // Find the class - UClass* FoundClass = nullptr; - for (TObjectIterator It; It; ++It) - { - if (It->GetName() == ClassName || It->GetName() == ClassName + TEXT("_C")) - { - FoundClass = *It; - break; - } - } - if (!FoundClass) - { - return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Class '%s' not found"), *ClassName)); - } - - TArray> PropList; - - for (TFieldIterator 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 PropObj = MakeShared(); - 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> Flags; - if (Prop->HasAnyPropertyFlags(CPF_BlueprintVisible)) Flags.Add(MakeShared(TEXT("BlueprintVisible"))); - if (Prop->HasAnyPropertyFlags(CPF_BlueprintReadOnly)) Flags.Add(MakeShared(TEXT("BlueprintReadOnly"))); - if (Prop->HasAnyPropertyFlags(CPF_Edit)) Flags.Add(MakeShared(TEXT("EditAnywhere"))); - if (Prop->HasAnyPropertyFlags(CPF_EditConst)) Flags.Add(MakeShared(TEXT("VisibleOnly"))); - if (Prop->HasAnyPropertyFlags(CPF_Config)) Flags.Add(MakeShared(TEXT("Config"))); - if (Prop->HasAnyPropertyFlags(CPF_SaveGame)) Flags.Add(MakeShared(TEXT("SaveGame"))); - if (Prop->HasAnyPropertyFlags(CPF_Transient)) Flags.Add(MakeShared(TEXT("Transient"))); - if (Prop->HasAnyPropertyFlags(CPF_RepNotify)) Flags.Add(MakeShared(TEXT("RepNotify"))); - if (Flags.Num() > 0) - { - PropObj->SetArrayField(TEXT("flags"), Flags); - } - - PropList.Add(MakeShared(PropObj)); - } - - Result->SetBoolField(TEXT("success"), true); - Result->SetStringField(TEXT("className"), FoundClass->GetName()); - Result->SetNumberField(TEXT("count"), PropList.Num()); - Result->SetArrayField(TEXT("properties"), PropList); -} diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Discovery.h b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Discovery.h new file mode 100644 index 00000000..5039551f --- /dev/null +++ b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Discovery.h @@ -0,0 +1,595 @@ +#pragma once + +#include "CoreMinimal.h" +#include "MCPHandler.h" +#include "MCPAssetFinder.h" +#include "MCPUtils.h" +#include "Engine/Blueprint.h" +#include "EdGraph/EdGraph.h" +#include "EdGraph/EdGraphNode.h" +#include "EdGraph/EdGraphPin.h" +#include "EdGraphSchema_K2.h" +#include "UObject/UObjectIterator.h" +#include "MCPHandlers_Discovery.generated.h" + +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + +// ============================================================ +// HandleGetPinInfo — detailed information about a specific pin +// ============================================================ + +UCLASS(meta=(ToolName="get_pin_details")) +class UMCPHandler_GetPinInfo : public UObject, public IMCPHandler +{ + GENERATED_BODY() + +public: + UPROPERTY(meta=(Description="Blueprint name or package path")) + FString Blueprint; + + UPROPERTY(meta=(Description="Node to look up (GUID)")) + FString Node; + + UPROPERTY(meta=(Description="Pin name on the node")) + FString PinName; + + virtual FString GetDescription() const override + { + return TEXT("Get detailed information about a specific pin on a blueprint node, " + "including type, connections, and default values."); + } + + virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override + { + FString LoadError; + UBlueprint* BP = UMCPAssetFinder::LoadBlueprintOrLevelBlueprint(Blueprint, LoadError); + if (!BP) + { + return MCPUtils::MakeErrorJson(Result, LoadError); + } + + UEdGraph* Graph = nullptr; + UEdGraphNode* FoundNode = MCPUtils::FindNodeByGuid(BP, Node, &Graph); + if (!FoundNode) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Node '%s' not found"), *Node)); + } + + UEdGraphPin* Pin = FoundNode->FindPin(FName(*PinName)); + if (!Pin) + { + // List available pins + TArray> AvailPins; + for (UEdGraphPin* P : FoundNode->Pins) + { + if (P) + { + TSharedRef PinObj = MakeShared(); + 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(PinObj)); + } + } + MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Pin '%s' not found on node '%s'"), *PinName, *Node)); + Result->SetArrayField(TEXT("availablePins"), AvailPins); + return; + } + + Result->SetBoolField(TEXT("success"), true); + Result->SetStringField(TEXT("blueprint"), Blueprint); + Result->SetStringField(TEXT("nodeId"), Node); + 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> Conns; + for (UEdGraphPin* Linked : Pin->LinkedTo) + { + if (!Linked || !Linked->GetOwningNode()) continue; + TSharedRef CJ = MakeShared(); + 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(CJ)); + } + Result->SetArrayField(TEXT("connectedTo"), Conns); + } + } +}; + +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + +// ============================================================ +// HandleCheckPinCompatibility — pre-flight check for connect_pins +// ============================================================ + +UCLASS(meta=(ToolName="check_pin_connection_compatibility")) +class UMCPHandler_CheckPinCompatibility : public UObject, public IMCPHandler +{ + GENERATED_BODY() + +public: + UPROPERTY(meta=(Description="Blueprint name or package path")) + FString Blueprint; + + UPROPERTY(meta=(Description="Source node (GUID)")) + FString SourceNode; + + UPROPERTY(meta=(Description="Source pin name")) + FString SourcePinName; + + UPROPERTY(meta=(Description="Target node (GUID)")) + FString TargetNode; + + UPROPERTY(meta=(Description="Target pin name")) + FString TargetPinName; + + virtual FString GetDescription() const override + { + return TEXT("Check whether two pins can be connected, and what kind of connection would result. " + "Use as a pre-flight check before connect_blueprint_pins."); + } + + virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override + { + FString LoadError; + UBlueprint* BP = UMCPAssetFinder::LoadBlueprintOrLevelBlueprint(Blueprint, LoadError); + if (!BP) + { + return MCPUtils::MakeErrorJson(Result, LoadError); + } + + UEdGraph* SourceGraph = nullptr; + UEdGraphNode* FoundSourceNode = MCPUtils::FindNodeByGuid(BP, SourceNode, &SourceGraph); + if (!FoundSourceNode) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Source node '%s' not found"), *SourceNode)); + } + + UEdGraphNode* FoundTargetNode = MCPUtils::FindNodeByGuid(BP, TargetNode); + if (!FoundTargetNode) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Target node '%s' not found"), *TargetNode)); + } + + UEdGraphPin* SourcePin = FoundSourceNode->FindPin(FName(*SourcePinName)); + if (!SourcePin) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Source pin '%s' not found on node '%s'"), *SourcePinName, *SourceNode)); + } + + UEdGraphPin* TargetPin = FoundTargetNode->FindPin(FName(*TargetPinName)); + if (!TargetPin) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Target pin '%s' not found on node '%s'"), *TargetPinName, *TargetNode)); + } + + const UEdGraphSchema* Schema = SourceGraph ? SourceGraph->GetSchema() : nullptr; + if (!Schema) + { + return MCPUtils::MakeErrorJson(Result, TEXT("Graph schema not found")); + } + + // Check compatibility using the schema + const FPinConnectionResponse Response = Schema->CanCreateConnection(SourcePin, TargetPin); + + Result->SetBoolField(TEXT("success"), true); + Result->SetStringField(TEXT("blueprint"), Blueprint); + + 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()); + } +}; + +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + +// ============================================================ +// HandleListClasses — discover available UClasses +// ============================================================ + +UCLASS(meta=(ToolName="search_unreal_classes")) +class UMCPHandler_ListClasses : public UObject, public IMCPHandler +{ + GENERATED_BODY() + +public: + UPROPERTY(meta=(Optional, Description="Substring filter for class names")) + FString Filter; + + UPROPERTY(meta=(Optional, Description="Parent class name to restrict results to subclasses")) + FString ParentClass; + + UPROPERTY(meta=(Optional, Description="Maximum number of results to return (1-500, default 100)")) + int32 Limit = 100; + + virtual FString GetDescription() const override + { + return TEXT("Search for available UClasses by name substring and/or parent class. " + "Returns class metadata including flags, parent class, and package."); + } + + virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override + { + if (Json->HasField(TEXT("limit"))) + { + Limit = FMath::Clamp(Limit, 1, 500); + } + + UClass* ParentClassObj = nullptr; + if (!ParentClass.IsEmpty()) + { + for (TObjectIterator It; It; ++It) + { + if (It->GetName() == ParentClass || It->GetName() == ParentClass + TEXT("_C")) + { + ParentClassObj = *It; + break; + } + } + if (!ParentClassObj) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Parent class '%s' not found"), *ParentClass)); + } + } + + TArray> ClassList; + int32 TotalMatched = 0; + + for (TObjectIterator 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 (ParentClassObj && !Class->IsChildOf(ParentClassObj)) 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 ClassObj = MakeShared(); + 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> Flags; + if (Class->HasAnyClassFlags(CLASS_Abstract)) Flags.Add(MakeShared(TEXT("Abstract"))); + if (Class->HasAnyClassFlags(CLASS_Interface)) Flags.Add(MakeShared(TEXT("Interface"))); + if (Class->HasAnyClassFlags(CLASS_MinimalAPI)) Flags.Add(MakeShared(TEXT("MinimalAPI"))); + if (Flags.Num() > 0) + { + ClassObj->SetArrayField(TEXT("flags"), Flags); + } + + ClassList.Add(MakeShared(ClassObj)); + } + + 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); + } +}; + +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + +// ============================================================ +// HandleListFunctions — list Blueprint-callable functions on a class +// ============================================================ + +UCLASS(meta=(ToolName="list_class_functions")) +class UMCPHandler_ListFunctions : public UObject, public IMCPHandler +{ + GENERATED_BODY() + +public: + UPROPERTY(meta=(Description="Class name to list functions for")) + FString ClassName; + + UPROPERTY(meta=(Optional, Description="Substring filter for function names")) + FString Filter; + + virtual FString GetDescription() const override + { + return TEXT("List Blueprint-callable functions on a UClass, including parameter info and flags."); + } + + virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override + { + // Find the class + UClass* FoundClass = nullptr; + for (TObjectIterator It; It; ++It) + { + if (It->GetName() == ClassName || It->GetName() == ClassName + TEXT("_C")) + { + FoundClass = *It; + break; + } + } + if (!FoundClass) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Class '%s' not found"), *ClassName)); + } + + TArray> FuncList; + + for (TFieldIterator 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 FuncObj = MakeShared(); + 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> Params; + FString ReturnType; + for (TFieldIterator 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 ParamObj = MakeShared(); + 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(ParamObj)); + } + } + FuncObj->SetArrayField(TEXT("parameters"), Params); + if (!ReturnType.IsEmpty()) + { + FuncObj->SetStringField(TEXT("returnType"), ReturnType); + } + + FuncList.Add(MakeShared(FuncObj)); + } + + Result->SetBoolField(TEXT("success"), true); + Result->SetStringField(TEXT("className"), FoundClass->GetName()); + Result->SetNumberField(TEXT("count"), FuncList.Num()); + Result->SetArrayField(TEXT("functions"), FuncList); + } +}; + +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + +// ============================================================ +// HandleListProperties — list properties on a class +// ============================================================ + +UCLASS(meta=(ToolName="list_class_properties")) +class UMCPHandler_ListProperties : public UObject, public IMCPHandler +{ + GENERATED_BODY() + +public: + UPROPERTY(meta=(Description="Class name to list properties for")) + FString ClassName; + + UPROPERTY(meta=(Optional, Description="Substring filter for property names")) + FString Filter; + + virtual FString GetDescription() const override + { + return TEXT("List properties on a UClass, including type, owning class, and property flags."); + } + + virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override + { + // Find the class + UClass* FoundClass = nullptr; + for (TObjectIterator It; It; ++It) + { + if (It->GetName() == ClassName || It->GetName() == ClassName + TEXT("_C")) + { + FoundClass = *It; + break; + } + } + if (!FoundClass) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Class '%s' not found"), *ClassName)); + } + + TArray> PropList; + + for (TFieldIterator 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 PropObj = MakeShared(); + 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> Flags; + if (Prop->HasAnyPropertyFlags(CPF_BlueprintVisible)) Flags.Add(MakeShared(TEXT("BlueprintVisible"))); + if (Prop->HasAnyPropertyFlags(CPF_BlueprintReadOnly)) Flags.Add(MakeShared(TEXT("BlueprintReadOnly"))); + if (Prop->HasAnyPropertyFlags(CPF_Edit)) Flags.Add(MakeShared(TEXT("EditAnywhere"))); + if (Prop->HasAnyPropertyFlags(CPF_EditConst)) Flags.Add(MakeShared(TEXT("VisibleOnly"))); + if (Prop->HasAnyPropertyFlags(CPF_Config)) Flags.Add(MakeShared(TEXT("Config"))); + if (Prop->HasAnyPropertyFlags(CPF_SaveGame)) Flags.Add(MakeShared(TEXT("SaveGame"))); + if (Prop->HasAnyPropertyFlags(CPF_Transient)) Flags.Add(MakeShared(TEXT("Transient"))); + if (Prop->HasAnyPropertyFlags(CPF_RepNotify)) Flags.Add(MakeShared(TEXT("RepNotify"))); + if (Flags.Num() > 0) + { + PropObj->SetArrayField(TEXT("flags"), Flags); + } + + PropList.Add(MakeShared(PropObj)); + } + + Result->SetBoolField(TEXT("success"), true); + Result->SetStringField(TEXT("className"), FoundClass->GetName()); + Result->SetNumberField(TEXT("count"), PropList.Num()); + Result->SetArrayField(TEXT("properties"), PropList); + } +}; diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Dispatchers.cpp b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Dispatchers.cpp deleted file mode 100644 index 2fba8bda..00000000 --- a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Dispatchers.cpp +++ /dev/null @@ -1,227 +0,0 @@ -#include "MCPAssetFinder.h" -#include "MCPServer.h" -#include "MCPUtils.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 -// ============================================================ - -void FBlueprintMCPServer::HandleAddEventDispatcher(const FJsonObject* Json, FJsonObject* Result) -{ - FString BlueprintName = Json->GetStringField(TEXT("blueprint")); - FString DispatcherName = Json->GetStringField(TEXT("dispatcherName")); - - if (BlueprintName.IsEmpty() || DispatcherName.IsEmpty()) - { - return MCPUtils::MakeErrorJson(Result, TEXT("Missing required fields: blueprint, dispatcherName")); - } - - // Load Blueprint - FString LoadError; - UBlueprint* BP = UMCPAssetFinder::LoadBlueprintOrLevelBlueprint(BlueprintName, LoadError); - if (!BP) - { - return MCPUtils::MakeErrorJson(Result, LoadError); - } - - FName DispatcherFName(*DispatcherName); - - // Check for name uniqueness against existing variables - for (const FBPVariableDescription& Var : BP->NewVariables) - { - if (Var.VarName == DispatcherFName) - { - return MCPUtils::MakeErrorJson(Result, FString::Printf( - TEXT("A variable or dispatcher named '%s' already exists in Blueprint '%s'"), - *DispatcherName, *BlueprintName)); - } - } - - // Check against existing graphs (functions, macros, etc.) - TArray AllGraphs; - BP->GetAllGraphs(AllGraphs); - for (UEdGraph* Existing : AllGraphs) - { - if (Existing && Existing->GetName().Equals(DispatcherName, ESearchCase::IgnoreCase)) - { - return MCPUtils::MakeErrorJson(Result, 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 MCPUtils::MakeErrorJson(Result, FString::Printf( - TEXT("Failed to add delegate variable for '%s'"), *DispatcherName)); - } - - // Step 2: Create the signature graph - const UEdGraphSchema_K2* K2Schema = GetDefault(); - - UEdGraph* SigGraph = FBlueprintEditorUtils::CreateNewGraph(BP, DispatcherFName, - UEdGraph::StaticClass(), UEdGraphSchema_K2::StaticClass()); - if (!SigGraph) - { - return MCPUtils::MakeErrorJson(Result, TEXT("Failed to create delegate signature graph")); - } - - K2Schema->CreateDefaultNodesForGraph(*SigGraph); - K2Schema->CreateFunctionGraphTerminators(*SigGraph, static_cast(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> ParamsArr; - if (Json->HasField(TEXT("parameters"))) - { - ParamsArr = Json->GetArrayField(TEXT("parameters")); - } - - TArray> 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(Node)) - { - EntryNode = FE; - break; - } - } - - if (!EntryNode) - { - // Still save what we have - FBlueprintEditorUtils::MarkBlueprintAsStructurallyModified(BP); - MCPUtils::SaveBlueprintPackage(BP); - return MCPUtils::MakeErrorJson(Result, TEXT("Event dispatcher created but entry node not found — parameters could not be added")); - } - - for (const TSharedPtr& ParamVal : ParamsArr) - { - if (!ParamVal.IsValid() || ParamVal->Type != EJson::Object) continue; - TSharedPtr ParamObj = ParamVal->AsObject(); - - FString ParamName = ParamObj->GetStringField(TEXT("name")); - FString ParamType = ParamObj->GetStringField(TEXT("type")); - - if (ParamName.IsEmpty() || ParamType.IsEmpty()) continue; - - FEdGraphPinType PinType; - if (!MCPUtils::ResolveTypeFromString(ParamType, PinType, Result)) - return; - - EntryNode->CreateUserDefinedPin(FName(*ParamName), PinType, EGPD_Output); - - TSharedRef ParamJson = MakeShared(); - ParamJson->SetStringField(TEXT("name"), ParamName); - ParamJson->SetStringField(TEXT("type"), ParamType); - AddedParamsJson.Add(MakeShared(ParamJson)); - } - } - - FBlueprintEditorUtils::MarkBlueprintAsStructurallyModified(BP); - bool bSaved = MCPUtils::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")); - - 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); -} - -// ============================================================ -// HandleListEventDispatchers — list all event dispatchers on a Blueprint -// ============================================================ - -void FBlueprintMCPServer::HandleListEventDispatchers(const FJsonObject* Json, FJsonObject* Result) -{ - FString BlueprintName = Json->GetStringField(TEXT("blueprint")); - if (BlueprintName.IsEmpty()) - { - return MCPUtils::MakeErrorJson(Result, TEXT("Missing required field: blueprint")); - } - - FString LoadError; - UBlueprint* BP = UMCPAssetFinder::LoadBlueprintOrLevelBlueprint(BlueprintName, LoadError); - if (!BP) - { - return MCPUtils::MakeErrorJson(Result, LoadError); - } - - TSet DelegateNameSet; - FBlueprintEditorUtils::GetDelegateNameList(BP, DelegateNameSet); - - TArray> DispatchersArr; - - for (const FName& DelegateName : DelegateNameSet) - { - TSharedRef DispObj = MakeShared(); - DispObj->SetStringField(TEXT("name"), DelegateName.ToString()); - - // Get parameter info from the signature graph - TArray> ParamsArr; - - UEdGraph* SigGraph = FBlueprintEditorUtils::GetDelegateSignatureGraphByName(BP, DelegateName); - if (SigGraph) - { - for (UEdGraphNode* Node : SigGraph->Nodes) - { - UK2Node_FunctionEntry* FE = Cast(Node); - if (!FE) continue; - - for (const TSharedPtr& PinInfo : FE->UserDefinedPins) - { - if (!PinInfo.IsValid()) continue; - - TSharedRef ParamObj = MakeShared(); - 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(ParamObj)); - } - break; // only need the first entry node - } - } - - DispObj->SetArrayField(TEXT("parameters"), ParamsArr); - DispatchersArr.Add(MakeShared(DispObj)); - } - - Result->SetStringField(TEXT("blueprint"), BlueprintName); - Result->SetNumberField(TEXT("count"), DispatchersArr.Num()); - Result->SetArrayField(TEXT("dispatchers"), DispatchersArr); -} diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Dispatchers.h b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Dispatchers.h new file mode 100644 index 00000000..92c1d98b --- /dev/null +++ b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Dispatchers.h @@ -0,0 +1,243 @@ +#pragma once + +#include "CoreMinimal.h" +#include "MCPHandler.h" +#include "MCPAssetFinder.h" +#include "MCPUtils.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 "MCPHandlers_Dispatchers.generated.h" + +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + +UCLASS(meta=(ToolName="add_event_dispatcher")) +class UMCPHandler_AddEventDispatcher : public UObject, public IMCPHandler +{ + GENERATED_BODY() + +public: + UPROPERTY(meta=(Description="Blueprint name or package path")) + FString Blueprint; + + UPROPERTY(meta=(Description="Name for the new event dispatcher")) + FString DispatcherName; + + UPROPERTY(meta=(Optional, Description="Array of parameter objects, each with 'name' and 'type' fields")) + FMCPJsonArray Parameters; + + virtual FString GetDescription() const override + { + return TEXT("Create a new multicast event dispatcher on a Blueprint, optionally with parameters."); + } + + virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override + { + // Load Blueprint + FString LoadError; + UBlueprint* BP = UMCPAssetFinder::LoadBlueprintOrLevelBlueprint(Blueprint, LoadError); + if (!BP) + { + return MCPUtils::MakeErrorJson(Result, LoadError); + } + + FName DispatcherFName(*DispatcherName); + + // Check for name uniqueness against existing variables + for (const FBPVariableDescription& Var : BP->NewVariables) + { + if (Var.VarName == DispatcherFName) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf( + TEXT("A variable or dispatcher named '%s' already exists in Blueprint '%s'"), + *DispatcherName, *Blueprint)); + } + } + + // Check against existing graphs (functions, macros, etc.) + TArray AllGraphs; + BP->GetAllGraphs(AllGraphs); + for (UEdGraph* Existing : AllGraphs) + { + if (Existing && Existing->GetName().Equals(DispatcherName, ESearchCase::IgnoreCase)) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf( + TEXT("A graph named '%s' already exists in Blueprint '%s'"), + *DispatcherName, *Blueprint)); + } + } + + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Adding event dispatcher '%s' to Blueprint '%s'"), + *DispatcherName, *Blueprint); + + // 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 MCPUtils::MakeErrorJson(Result, FString::Printf( + TEXT("Failed to add delegate variable for '%s'"), *DispatcherName)); + } + + // Step 2: Create the signature graph + const UEdGraphSchema_K2* K2Schema = GetDefault(); + + UEdGraph* SigGraph = FBlueprintEditorUtils::CreateNewGraph(BP, DispatcherFName, + UEdGraph::StaticClass(), UEdGraphSchema_K2::StaticClass()); + if (!SigGraph) + { + return MCPUtils::MakeErrorJson(Result, TEXT("Failed to create delegate signature graph")); + } + + K2Schema->CreateDefaultNodesForGraph(*SigGraph); + K2Schema->CreateFunctionGraphTerminators(*SigGraph, static_cast(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> AddedParamsJson; + + if (Parameters.Array.Num() > 0) + { + // Find the entry node in the signature graph + UK2Node_EditablePinBase* EntryNode = nullptr; + for (UEdGraphNode* Node : SigGraph->Nodes) + { + if (UK2Node_FunctionEntry* FE = Cast(Node)) + { + EntryNode = FE; + break; + } + } + + if (!EntryNode) + { + // Still save what we have + FBlueprintEditorUtils::MarkBlueprintAsStructurallyModified(BP); + MCPUtils::SaveBlueprintPackage(BP); + return MCPUtils::MakeErrorJson(Result, TEXT("Event dispatcher created but entry node not found — parameters could not be added")); + } + + for (const TSharedPtr& ParamVal : Parameters.Array) + { + if (!ParamVal.IsValid() || ParamVal->Type != EJson::Object) continue; + TSharedPtr ParamObj = ParamVal->AsObject(); + + FString ParamName = ParamObj->GetStringField(TEXT("name")); + FString ParamType = ParamObj->GetStringField(TEXT("type")); + + if (ParamName.IsEmpty() || ParamType.IsEmpty()) continue; + + FEdGraphPinType PinType; + if (!MCPUtils::ResolveTypeFromString(ParamType, PinType, Result)) + return; + + EntryNode->CreateUserDefinedPin(FName(*ParamName), PinType, EGPD_Output); + + TSharedRef ParamJson = MakeShared(); + ParamJson->SetStringField(TEXT("name"), ParamName); + ParamJson->SetStringField(TEXT("type"), ParamType); + AddedParamsJson.Add(MakeShared(ParamJson)); + } + } + + FBlueprintEditorUtils::MarkBlueprintAsStructurallyModified(BP); + bool bSaved = MCPUtils::SaveBlueprintPackage(BP); + + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Added event dispatcher '%s' to '%s' with %d params (saved: %s)"), + *DispatcherName, *Blueprint, AddedParamsJson.Num(), bSaved ? TEXT("true") : TEXT("false")); + + Result->SetBoolField(TEXT("success"), true); + Result->SetStringField(TEXT("blueprint"), Blueprint); + Result->SetStringField(TEXT("dispatcherName"), DispatcherName); + Result->SetArrayField(TEXT("parameters"), AddedParamsJson); + Result->SetBoolField(TEXT("saved"), bSaved); + } +}; + +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + +UCLASS(meta=(ToolName="list_event_dispatchers")) +class UMCPHandler_ListEventDispatchers : public UObject, public IMCPHandler +{ + GENERATED_BODY() + +public: + UPROPERTY(meta=(Description="Blueprint name or package path")) + FString Blueprint; + + virtual FString GetDescription() const override + { + return TEXT("List all event dispatchers on a Blueprint, including their parameter signatures."); + } + + virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override + { + FString LoadError; + UBlueprint* BP = UMCPAssetFinder::LoadBlueprintOrLevelBlueprint(Blueprint, LoadError); + if (!BP) + { + return MCPUtils::MakeErrorJson(Result, LoadError); + } + + TSet DelegateNameSet; + FBlueprintEditorUtils::GetDelegateNameList(BP, DelegateNameSet); + + TArray> DispatchersArr; + + for (const FName& DelegateName : DelegateNameSet) + { + TSharedRef DispObj = MakeShared(); + DispObj->SetStringField(TEXT("name"), DelegateName.ToString()); + + // Get parameter info from the signature graph + TArray> ParamsArr; + + UEdGraph* SigGraph = FBlueprintEditorUtils::GetDelegateSignatureGraphByName(BP, DelegateName); + if (SigGraph) + { + for (UEdGraphNode* Node : SigGraph->Nodes) + { + UK2Node_FunctionEntry* FE = Cast(Node); + if (!FE) continue; + + for (const TSharedPtr& PinInfo : FE->UserDefinedPins) + { + if (!PinInfo.IsValid()) continue; + + TSharedRef ParamObj = MakeShared(); + 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(ParamObj)); + } + break; // only need the first entry node + } + } + + DispObj->SetArrayField(TEXT("parameters"), ParamsArr); + DispatchersArr.Add(MakeShared(DispObj)); + } + + Result->SetStringField(TEXT("blueprint"), Blueprint); + Result->SetNumberField(TEXT("count"), DispatchersArr.Num()); + Result->SetArrayField(TEXT("dispatchers"), DispatchersArr); + } +}; diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Graphs.cpp b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Graphs.cpp deleted file mode 100644 index b60df52e..00000000 --- a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Graphs.cpp +++ /dev/null @@ -1,570 +0,0 @@ -#include "MCPAssetFinder.h" -#include "MCPServer.h" -#include "MCPUtils.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 -// ============================================================ - -void FBlueprintMCPServer::HandleReparentBlueprint(const FJsonObject* Json, FJsonObject* Result) -{ - FString BlueprintName = Json->GetStringField(TEXT("blueprint")); - FString NewParentName = Json->GetStringField(TEXT("newParentClass")); - - if (BlueprintName.IsEmpty() || NewParentName.IsEmpty()) - { - return MCPUtils::MakeErrorJson(Result, TEXT("Missing required fields: blueprint, newParentClass")); - } - - // Load Blueprint - FString LoadError; - UBlueprint* BP = UMCPAssetFinder::LoadBlueprintOrLevelBlueprint(BlueprintName, LoadError); - if (!BP) - { - return MCPUtils::MakeErrorJson(Result, 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 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 = UMCPAssetFinder::LoadBlueprintOrLevelBlueprint(NewParentName, ParentLoadError); - if (ParentBP && ParentBP->GeneratedClass) - { - NewParentClass = ParentBP->GeneratedClass; - } - } - - if (!NewParentClass) - { - return MCPUtils::MakeErrorJson(Result, 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 = MCPUtils::SaveBlueprintPackage(BP); - - FString NewParentActualName = NewParentClass->GetName(); - - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Reparent complete, save %s"), - bSaved ? TEXT("succeeded") : TEXT("failed")); - - 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); -} - -// ============================================================ -// HandleCreateBlueprint — create a new Blueprint asset -// ============================================================ - -void FBlueprintMCPServer::HandleCreateBlueprint(const FJsonObject* Json, FJsonObject* Result) -{ - 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 MCPUtils::MakeErrorJson(Result, TEXT("Missing required fields: blueprintName, packagePath, parentClass")); - } - - // Validate packagePath starts with /Game - if (!PackagePath.StartsWith(TEXT("/Game"))) - { - return MCPUtils::MakeErrorJson(Result, TEXT("packagePath must start with '/Game'")); - } - - // Check if asset already exists - FString FullAssetPath = PackagePath / BlueprintName; - if (UMCPAssetFinder::FindAsset(UBlueprint::StaticClass(), BlueprintName) || UMCPAssetFinder::FindAsset(UBlueprint::StaticClass(), FullAssetPath)) - { - return MCPUtils::MakeErrorJson(Result, 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 It; It; ++It) - { - if (It->GetName() == ParentClassName) - { - ParentClass = *It; - break; - } - } - - if (!ParentClass) - { - FString ParentLoadError; - UBlueprint* ParentBP = UMCPAssetFinder::LoadBlueprintOrLevelBlueprint(ParentClassName, ParentLoadError); - if (ParentBP && ParentBP->GeneratedClass) - { - ParentClass = ParentBP->GeneratedClass; - } - } - - if (!ParentClass) - { - return MCPUtils::MakeErrorJson(Result, 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 MCPUtils::MakeErrorJson(Result, 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 MCPUtils::MakeErrorJson(Result, 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 MCPUtils::MakeErrorJson(Result, TEXT("FKismetEditorUtilities::CreateBlueprint returned null")); - } - - // Compile - FKismetEditorUtilities::CompileBlueprint(NewBP); - - // Save - bool bSaved = MCPUtils::SaveBlueprintPackage(NewBP); - - - // Collect graph names - TArray> GraphNames; - for (UEdGraph* Graph : NewBP->UbergraphPages) - { - GraphNames.Add(MakeShared(Graph->GetName())); - } - for (UEdGraph* Graph : NewBP->FunctionGraphs) - { - GraphNames.Add(MakeShared(Graph->GetName())); - } - for (UEdGraph* Graph : NewBP->MacroGraphs) - { - GraphNames.Add(MakeShared(Graph->GetName())); - } - - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Created Blueprint '%s' with %d graphs (saved: %s)"), - *BlueprintName, GraphNames.Num(), bSaved ? TEXT("true") : TEXT("false")); - - 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); -} - -// ============================================================ -// HandleCreateGraph — create a new function, macro, or custom event graph -// ============================================================ - -void FBlueprintMCPServer::HandleCreateGraph(const FJsonObject* Json, FJsonObject* Result) -{ - 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 MCPUtils::MakeErrorJson(Result, TEXT("Missing required fields: blueprint, graphName, graphType")); - } - - if (GraphType != TEXT("function") && GraphType != TEXT("macro") && GraphType != TEXT("customEvent")) - { - return MCPUtils::MakeErrorJson(Result, FString::Printf( - TEXT("Invalid graphType '%s'. Valid values: function, macro, customEvent"), *GraphType)); - } - - // Load Blueprint - FString LoadError; - UBlueprint* BP = UMCPAssetFinder::LoadBlueprintOrLevelBlueprint(BlueprintName, LoadError); - if (!BP) - { - return MCPUtils::MakeErrorJson(Result, LoadError); - } - - // Check graph name uniqueness - TArray AllGraphs; - BP->GetAllGraphs(AllGraphs); - for (UEdGraph* Existing : AllGraphs) - { - if (Existing && Existing->GetName().Equals(GraphName, ESearchCase::IgnoreCase)) - { - return MCPUtils::MakeErrorJson(Result, 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(Node)) - { - if (CE->CustomFunctionName == FName(*GraphName)) - { - return MCPUtils::MakeErrorJson(Result, 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 MCPUtils::MakeErrorJson(Result, TEXT("Failed to create function graph")); - } - FBlueprintEditorUtils::AddFunctionGraph(BP, NewGraph, /*bIsUserCreated=*/true, /*SignatureFromObject=*/static_cast(nullptr)); - } - else if (GraphType == TEXT("macro")) - { - UEdGraph* NewGraph = FBlueprintEditorUtils::CreateNewGraph(BP, FName(*GraphName), - UEdGraph::StaticClass(), UEdGraphSchema_K2::StaticClass()); - if (!NewGraph) - { - return MCPUtils::MakeErrorJson(Result, 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 MCPUtils::MakeErrorJson(Result, TEXT("Blueprint has no EventGraph to add a custom event to")); - } - - // Create a custom event node in the EventGraph - UK2Node_CustomEvent* NewEvent = NewObject(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 = MCPUtils::SaveBlueprintPackage(BP); - - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Created %s graph '%s' in '%s' (saved: %s)"), - *GraphType, *GraphName, *BlueprintName, bSaved ? TEXT("true") : TEXT("false")); - - 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); - } -} - -// ============================================================ -// HandleDeleteGraph — delete a function or macro graph -// ============================================================ - -void FBlueprintMCPServer::HandleDeleteGraph(const FJsonObject* Json, FJsonObject* Result) -{ - FString BlueprintName = Json->GetStringField(TEXT("blueprint")); - FString GraphName = Json->GetStringField(TEXT("graphName")); - - if (BlueprintName.IsEmpty() || GraphName.IsEmpty()) - { - return MCPUtils::MakeErrorJson(Result, TEXT("Missing required fields: blueprint, graphName")); - } - - FString LoadError; - UBlueprint* BP = UMCPAssetFinder::LoadBlueprintOrLevelBlueprint(BlueprintName, LoadError); - if (!BP) - { - return MCPUtils::MakeErrorJson(Result, 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 MCPUtils::MakeErrorJson(Result, FString::Printf( - TEXT("Cannot delete UbergraphPage '%s'. EventGraph and other Ubergraph pages cannot be deleted."), - *GraphName)); - } - } - return MCPUtils::MakeErrorJson(Result, 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 = MCPUtils::SaveBlueprintPackage(BP); - - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Deleted graph '%s' (%d nodes), save %s"), - *GraphName, NodeCount, bSaved ? TEXT("true") : TEXT("false")); - - 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); -} - -// ============================================================ -// HandleRenameGraph — rename a function or macro graph -// ============================================================ - -void FBlueprintMCPServer::HandleRenameGraph(const FJsonObject* Json, FJsonObject* Result) -{ - 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 MCPUtils::MakeErrorJson(Result, TEXT("Missing required fields: blueprint, graphName, newName")); - } - - FString LoadError; - UBlueprint* BP = UMCPAssetFinder::LoadBlueprintOrLevelBlueprint(BlueprintName, LoadError); - if (!BP) - { - return MCPUtils::MakeErrorJson(Result, LoadError); - } - - // Check if it's an UbergraphPage — disallow rename - for (UEdGraph* Graph : BP->UbergraphPages) - { - if (Graph && Graph->GetName().Equals(GraphName, ESearchCase::IgnoreCase)) - { - return MCPUtils::MakeErrorJson(Result, 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 MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Graph '%s' not found in Blueprint '%s'"), *GraphName, *BlueprintName)); - } - - // Check for name collision - TArray AllGraphs; - BP->GetAllGraphs(AllGraphs); - for (UEdGraph* Existing : AllGraphs) - { - if (Existing && Existing != TargetGraph && Existing->GetName().Equals(NewName, ESearchCase::IgnoreCase)) - { - return MCPUtils::MakeErrorJson(Result, 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 = MCPUtils::SaveBlueprintPackage(BP); - - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Renamed graph '%s' to '%s', save %s"), - *GraphName, *NewName, bSaved ? TEXT("true") : TEXT("false")); - - 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); -} diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Graphs.h b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Graphs.h new file mode 100644 index 00000000..02d23a25 --- /dev/null +++ b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Graphs.h @@ -0,0 +1,625 @@ +#pragma once + +#include "CoreMinimal.h" +#include "MCPHandler.h" +#include "MCPAssetFinder.h" +#include "MCPUtils.h" +#include "Engine/Blueprint.h" +#include "EdGraph/EdGraph.h" +#include "EdGraph/EdGraphNode.h" +#include "EdGraphSchema_K2.h" +#include "K2Node_CustomEvent.h" +#include "Kismet2/BlueprintEditorUtils.h" +#include "Kismet2/KismetEditorUtilities.h" +#include "UObject/UObjectIterator.h" +#include "MCPHandlers_Graphs.generated.h" + +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + +UCLASS(meta=(ToolName="reparent_blueprint")) +class UMCPHandler_ReparentBlueprint : public UObject, public IMCPHandler +{ + GENERATED_BODY() + +public: + UPROPERTY(meta=(Description="Blueprint name or package path")) + FString Blueprint; + + UPROPERTY(meta=(Description="Name of the new parent class (C++ class name or Blueprint name)")) + FString NewParentClass; + + virtual FString GetDescription() const override + { + return TEXT("Change a Blueprint's parent class. Accepts C++ class names or Blueprint names."); + } + + virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override + { + // Load Blueprint + FString LoadError; + UBlueprint* BP = UMCPAssetFinder::LoadBlueprintOrLevelBlueprint(Blueprint, LoadError); + if (!BP) + { + return MCPUtils::MakeErrorJson(Result, 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* NewParentClassObj = nullptr; + + // Search across all packages for native classes + for (TObjectIterator It; It; ++It) + { + if (It->GetName() == NewParentClass) + { + NewParentClassObj = *It; + break; + } + } + + // If not found as C++ class, try loading as a Blueprint asset + if (!NewParentClassObj) + { + FString ParentLoadError; + UBlueprint* ParentBP = UMCPAssetFinder::LoadBlueprintOrLevelBlueprint(NewParentClass, ParentLoadError); + if (ParentBP && ParentBP->GeneratedClass) + { + NewParentClassObj = ParentBP->GeneratedClass; + } + } + + if (!NewParentClassObj) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf( + TEXT("Could not find class '%s'. Provide a C++ class name (e.g. 'WebUIHUD') or Blueprint name."), + *NewParentClass)); + } + + // Validate: new parent must be compatible + if (BP->ParentClass && !NewParentClassObj->IsChildOf(BP->ParentClass->GetSuperClass()) && + BP->ParentClass != NewParentClassObj) + { + // 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"), + *Blueprint, *OldParentName, *NewParentClassObj->GetName()); + } + + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Reparenting '%s' from '%s' to '%s'"), + *Blueprint, *OldParentName, *NewParentClassObj->GetName()); + + // Perform reparent + BP->ParentClass = NewParentClassObj; + + // Refresh all nodes to pick up new parent's functions/variables + FBlueprintEditorUtils::RefreshAllNodes(BP); + + // Compile + FKismetEditorUtilities::CompileBlueprint(BP); + + // Save + bool bSaved = MCPUtils::SaveBlueprintPackage(BP); + + FString NewParentActualName = NewParentClassObj->GetName(); + + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Reparent complete, save %s"), + bSaved ? TEXT("succeeded") : TEXT("failed")); + + Result->SetBoolField(TEXT("success"), true); + Result->SetStringField(TEXT("blueprint"), Blueprint); + Result->SetStringField(TEXT("oldParentClass"), OldParentName); + Result->SetStringField(TEXT("newParentClass"), NewParentActualName); + Result->SetBoolField(TEXT("saved"), bSaved); + } +}; + +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + +UCLASS(meta=(ToolName="create_blueprint_asset")) +class UMCPHandler_CreateBlueprint : public UObject, public IMCPHandler +{ + GENERATED_BODY() + +public: + UPROPERTY(meta=(Description="New Blueprint asset name")) + FString Blueprint; + + UPROPERTY(meta=(Description="Package path where the asset will be created (must start with /Game)")) + FString PackagePath; + + UPROPERTY(meta=(Description="Parent class name (C++ class name or Blueprint name)")) + FString ParentClass; + + UPROPERTY(meta=(Optional, Description="Blueprint type: Normal, Interface, FunctionLibrary, or MacroLibrary (default: Normal)")) + FString BlueprintType; + + virtual FString GetDescription() const override + { + return TEXT("Create a new Blueprint asset with a specified parent class and type."); + } + + virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override + { + // Validate packagePath starts with /Game + if (!PackagePath.StartsWith(TEXT("/Game"))) + { + return MCPUtils::MakeErrorJson(Result, TEXT("packagePath must start with '/Game'")); + } + + // Check if asset already exists + FString FullAssetPath = PackagePath / Blueprint; + if (UMCPAssetFinder::FindAsset(UBlueprint::StaticClass(), Blueprint) || UMCPAssetFinder::FindAsset(UBlueprint::StaticClass(), FullAssetPath)) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf( + TEXT("Blueprint '%s' already exists. Use a different name or delete the existing asset first."), + *Blueprint)); + } + + // Resolve parent class — try C++ class first, then Blueprint + UClass* ParentClassObj = nullptr; + + for (TObjectIterator It; It; ++It) + { + if (It->GetName() == ParentClass) + { + ParentClassObj = *It; + break; + } + } + + if (!ParentClassObj) + { + FString ParentLoadError; + UBlueprint* ParentBP = UMCPAssetFinder::LoadBlueprintOrLevelBlueprint(ParentClass, ParentLoadError); + if (ParentBP && ParentBP->GeneratedClass) + { + ParentClassObj = ParentBP->GeneratedClass; + } + } + + if (!ParentClassObj) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf( + TEXT("Could not find parent class '%s'. Provide a C++ class name (e.g. 'Actor', 'Pawn') or Blueprint name."), + *ParentClass)); + } + + // Map blueprintType string to EBlueprintType + EBlueprintType BlueprintTypeEnum = BPTYPE_Normal; + if (!BlueprintType.IsEmpty()) + { + if (BlueprintType == TEXT("Interface")) + { + BlueprintTypeEnum = BPTYPE_Interface; + } + else if (BlueprintType == TEXT("FunctionLibrary")) + { + BlueprintTypeEnum = BPTYPE_FunctionLibrary; + } + else if (BlueprintType == TEXT("MacroLibrary")) + { + BlueprintTypeEnum = BPTYPE_MacroLibrary; + } + else if (BlueprintType != TEXT("Normal")) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf( + TEXT("Invalid blueprintType '%s'. Valid values: Normal, Interface, FunctionLibrary, MacroLibrary"), + *BlueprintType)); + } + } + + // For Interface type, parent must be UInterface + if ((BlueprintTypeEnum == BPTYPE_Interface) && !ParentClassObj->IsChildOf(UInterface::StaticClass())) + { + // Use the engine's standard BlueprintInterface parent + ParentClassObj = UInterface::StaticClass(); + } + + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Creating Blueprint '%s' in '%s' with parent '%s' (type=%s)"), + *Blueprint, *PackagePath, *ParentClassObj->GetName(), *BlueprintType); + + // Create the package + FString FullPackagePath = PackagePath / Blueprint; + UPackage* Package = CreatePackage(*FullPackagePath); + if (!Package) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Failed to create package at '%s'"), *FullPackagePath)); + } + + // Create the Blueprint + UBlueprint* NewBP = FKismetEditorUtilities::CreateBlueprint( + ParentClassObj, + Package, + FName(*Blueprint), + BlueprintTypeEnum, + UBlueprint::StaticClass(), + UBlueprintGeneratedClass::StaticClass() + ); + + if (!NewBP) + { + return MCPUtils::MakeErrorJson(Result, TEXT("FKismetEditorUtilities::CreateBlueprint returned null")); + } + + // Compile + FKismetEditorUtilities::CompileBlueprint(NewBP); + + // Save + bool bSaved = MCPUtils::SaveBlueprintPackage(NewBP); + + + // Collect graph names + TArray> GraphNames; + for (UEdGraph* Graph : NewBP->UbergraphPages) + { + GraphNames.Add(MakeShared(Graph->GetName())); + } + for (UEdGraph* Graph : NewBP->FunctionGraphs) + { + GraphNames.Add(MakeShared(Graph->GetName())); + } + for (UEdGraph* Graph : NewBP->MacroGraphs) + { + GraphNames.Add(MakeShared(Graph->GetName())); + } + + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Created Blueprint '%s' with %d graphs (saved: %s)"), + *Blueprint, GraphNames.Num(), bSaved ? TEXT("true") : TEXT("false")); + + Result->SetBoolField(TEXT("success"), true); + Result->SetStringField(TEXT("blueprintName"), Blueprint); + Result->SetStringField(TEXT("packagePath"), PackagePath); + Result->SetStringField(TEXT("assetPath"), FullAssetPath); + Result->SetStringField(TEXT("parentClass"), ParentClassObj->GetName()); + Result->SetStringField(TEXT("blueprintType"), BlueprintType.IsEmpty() ? TEXT("Normal") : *BlueprintType); + Result->SetBoolField(TEXT("saved"), bSaved); + Result->SetArrayField(TEXT("graphs"), GraphNames); + } +}; + +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + +UCLASS(meta=(ToolName="create_blueprint_graph")) +class UMCPHandler_CreateGraph : public UObject, public IMCPHandler +{ + GENERATED_BODY() + +public: + UPROPERTY(meta=(Description="Blueprint name or package path")) + FString Blueprint; + + UPROPERTY(meta=(Description="Name for the new graph")) + FString Graph; + + UPROPERTY(meta=(Description="Type of graph: function, macro, or customEvent")) + FString GraphType; + + virtual FString GetDescription() const override + { + return TEXT("Create a new function, macro, or custom event graph in a Blueprint."); + } + + virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override + { + if (GraphType != TEXT("function") && GraphType != TEXT("macro") && GraphType != TEXT("customEvent")) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf( + TEXT("Invalid graphType '%s'. Valid values: function, macro, customEvent"), *GraphType)); + } + + // Load Blueprint + FString LoadError; + UBlueprint* BP = UMCPAssetFinder::LoadBlueprintOrLevelBlueprint(Blueprint, LoadError); + if (!BP) + { + return MCPUtils::MakeErrorJson(Result, LoadError); + } + + // Check graph name uniqueness + TArray AllGraphs; + BP->GetAllGraphs(AllGraphs); + for (UEdGraph* Existing : AllGraphs) + { + if (Existing && Existing->GetName().Equals(Graph, ESearchCase::IgnoreCase)) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf( + TEXT("A graph named '%s' already exists in Blueprint '%s'"), *Graph, *Blueprint)); + } + } + + // Also check for existing custom events with the same name + if (GraphType == TEXT("customEvent")) + { + for (UEdGraph* ExistingGraph : AllGraphs) + { + if (!ExistingGraph) continue; + for (UEdGraphNode* Node : ExistingGraph->Nodes) + { + if (UK2Node_CustomEvent* CE = Cast(Node)) + { + if (CE->CustomFunctionName == FName(*Graph)) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf( + TEXT("A custom event named '%s' already exists in Blueprint '%s'"), *Graph, *Blueprint)); + } + } + } + } + } + + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Creating %s graph '%s' in Blueprint '%s'"), + *GraphType, *Graph, *Blueprint); + + FString CreatedNodeId; + + if (GraphType == TEXT("function")) + { + UEdGraph* NewGraph = FBlueprintEditorUtils::CreateNewGraph(BP, FName(*Graph), + UEdGraph::StaticClass(), UEdGraphSchema_K2::StaticClass()); + if (!NewGraph) + { + return MCPUtils::MakeErrorJson(Result, TEXT("Failed to create function graph")); + } + FBlueprintEditorUtils::AddFunctionGraph(BP, NewGraph, /*bIsUserCreated=*/true, /*SignatureFromObject=*/static_cast(nullptr)); + } + else if (GraphType == TEXT("macro")) + { + UEdGraph* NewGraph = FBlueprintEditorUtils::CreateNewGraph(BP, FName(*Graph), + UEdGraph::StaticClass(), UEdGraphSchema_K2::StaticClass()); + if (!NewGraph) + { + return MCPUtils::MakeErrorJson(Result, 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 MCPUtils::MakeErrorJson(Result, TEXT("Blueprint has no EventGraph to add a custom event to")); + } + + // Create a custom event node in the EventGraph + UK2Node_CustomEvent* NewEvent = NewObject(EventGraph); + NewEvent->CustomFunctionName = FName(*Graph); + 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 = MCPUtils::SaveBlueprintPackage(BP); + + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Created %s graph '%s' in '%s' (saved: %s)"), + *GraphType, *Graph, *Blueprint, bSaved ? TEXT("true") : TEXT("false")); + + Result->SetBoolField(TEXT("success"), true); + Result->SetStringField(TEXT("blueprint"), Blueprint); + Result->SetStringField(TEXT("graphName"), Graph); + Result->SetStringField(TEXT("graphType"), GraphType); + Result->SetBoolField(TEXT("saved"), bSaved); + if (!CreatedNodeId.IsEmpty()) + { + Result->SetStringField(TEXT("nodeId"), CreatedNodeId); + } + } +}; + +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + +UCLASS(meta=(ToolName="delete_blueprint_graph")) +class UMCPHandler_DeleteGraph : public UObject, public IMCPHandler +{ + GENERATED_BODY() + +public: + UPROPERTY(meta=(Description="Blueprint name or package path")) + FString Blueprint; + + UPROPERTY(meta=(Description="Name of the graph to delete")) + FString Graph; + + virtual FString GetDescription() const override + { + return TEXT("Delete a function or macro graph from a Blueprint. Cannot delete EventGraph pages."); + } + + virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override + { + FString LoadError; + UBlueprint* BP = UMCPAssetFinder::LoadBlueprintOrLevelBlueprint(Blueprint, LoadError); + if (!BP) + { + return MCPUtils::MakeErrorJson(Result, LoadError); + } + + // Find the graph + UEdGraph* TargetGraph = nullptr; + FString GraphType; + + for (UEdGraph* CandidateGraph : BP->FunctionGraphs) + { + if (CandidateGraph && CandidateGraph->GetName().Equals(Graph, ESearchCase::IgnoreCase)) + { + TargetGraph = CandidateGraph; + GraphType = TEXT("function"); + break; + } + } + if (!TargetGraph) + { + for (UEdGraph* CandidateGraph : BP->MacroGraphs) + { + if (CandidateGraph && CandidateGraph->GetName().Equals(Graph, ESearchCase::IgnoreCase)) + { + TargetGraph = CandidateGraph; + GraphType = TEXT("macro"); + break; + } + } + } + + // Check if it's an UbergraphPage (EventGraph) — disallow deletion + if (!TargetGraph) + { + for (UEdGraph* CandidateGraph : BP->UbergraphPages) + { + if (CandidateGraph && CandidateGraph->GetName().Equals(Graph, ESearchCase::IgnoreCase)) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf( + TEXT("Cannot delete UbergraphPage '%s'. EventGraph and other Ubergraph pages cannot be deleted."), + *Graph)); + } + } + return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Graph '%s' not found in Blueprint '%s'"), *Graph, *Blueprint)); + } + + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Deleting %s graph '%s' from Blueprint '%s'"), + *GraphType, *Graph, *Blueprint); + + // Count nodes for reporting + int32 NodeCount = TargetGraph->Nodes.Num(); + + // Remove the graph + FBlueprintEditorUtils::RemoveGraph(BP, TargetGraph, EGraphRemoveFlags::Default); + + FBlueprintEditorUtils::MarkBlueprintAsStructurallyModified(BP); + bool bSaved = MCPUtils::SaveBlueprintPackage(BP); + + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Deleted graph '%s' (%d nodes), save %s"), + *Graph, NodeCount, bSaved ? TEXT("true") : TEXT("false")); + + Result->SetBoolField(TEXT("success"), true); + Result->SetStringField(TEXT("blueprint"), Blueprint); + Result->SetStringField(TEXT("graphName"), Graph); + Result->SetStringField(TEXT("graphType"), GraphType); + Result->SetNumberField(TEXT("nodeCount"), NodeCount); + Result->SetBoolField(TEXT("saved"), bSaved); + } +}; + +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + +UCLASS(meta=(ToolName="rename_blueprint_graph")) +class UMCPHandler_RenameGraph : public UObject, public IMCPHandler +{ + GENERATED_BODY() + +public: + UPROPERTY(meta=(Description="Blueprint name or package path")) + FString Blueprint; + + UPROPERTY(meta=(Description="Current name of the graph to rename")) + FString Graph; + + UPROPERTY(meta=(Description="New name for the graph")) + FString NewName; + + virtual FString GetDescription() const override + { + return TEXT("Rename a function or macro graph in a Blueprint. Cannot rename EventGraph pages."); + } + + virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override + { + FString LoadError; + UBlueprint* BP = UMCPAssetFinder::LoadBlueprintOrLevelBlueprint(Blueprint, LoadError); + if (!BP) + { + return MCPUtils::MakeErrorJson(Result, LoadError); + } + + // Check if it's an UbergraphPage — disallow rename + for (UEdGraph* CandidateGraph : BP->UbergraphPages) + { + if (CandidateGraph && CandidateGraph->GetName().Equals(Graph, ESearchCase::IgnoreCase)) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf( + TEXT("Cannot rename UbergraphPage '%s'. EventGraph and other Ubergraph pages cannot be renamed."), + *Graph)); + } + } + + // Find the graph in FunctionGraphs or MacroGraphs + UEdGraph* TargetGraph = nullptr; + FString GraphType; + + for (UEdGraph* CandidateGraph : BP->FunctionGraphs) + { + if (CandidateGraph && CandidateGraph->GetName().Equals(Graph, ESearchCase::IgnoreCase)) + { + TargetGraph = CandidateGraph; + GraphType = TEXT("function"); + break; + } + } + if (!TargetGraph) + { + for (UEdGraph* CandidateGraph : BP->MacroGraphs) + { + if (CandidateGraph && CandidateGraph->GetName().Equals(Graph, ESearchCase::IgnoreCase)) + { + TargetGraph = CandidateGraph; + GraphType = TEXT("macro"); + break; + } + } + } + + if (!TargetGraph) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Graph '%s' not found in Blueprint '%s'"), *Graph, *Blueprint)); + } + + // Check for name collision + TArray AllGraphs; + BP->GetAllGraphs(AllGraphs); + for (UEdGraph* Existing : AllGraphs) + { + if (Existing && Existing != TargetGraph && Existing->GetName().Equals(NewName, ESearchCase::IgnoreCase)) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf( + TEXT("A graph named '%s' already exists in Blueprint '%s'"), *NewName, *Blueprint)); + } + } + + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Renaming %s graph '%s' to '%s' in Blueprint '%s'"), + *GraphType, *Graph, *NewName, *Blueprint); + + FBlueprintEditorUtils::RenameGraph(TargetGraph, NewName); + + FBlueprintEditorUtils::MarkBlueprintAsStructurallyModified(BP); + bool bSaved = MCPUtils::SaveBlueprintPackage(BP); + + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Renamed graph '%s' to '%s', save %s"), + *Graph, *NewName, bSaved ? TEXT("true") : TEXT("false")); + + Result->SetBoolField(TEXT("success"), true); + Result->SetStringField(TEXT("blueprint"), Blueprint); + Result->SetStringField(TEXT("oldName"), Graph); + Result->SetStringField(TEXT("newName"), TargetGraph->GetName()); + Result->SetStringField(TEXT("graphType"), GraphType); + Result->SetBoolField(TEXT("saved"), bSaved); + } +}; diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_MaterialInstance.cpp b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_MaterialInstance.cpp deleted file mode 100644 index b14f65f8..00000000 --- a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_MaterialInstance.cpp +++ /dev/null @@ -1,695 +0,0 @@ -#include "MCPAssetFinder.h" -#include "MCPServer.h" -#include "MCPUtils.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 -// ============================================================ - -void FBlueprintMCPServer::HandleCreateMaterialInstance(const FJsonObject* Json, FJsonObject* Result) -{ - 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 MCPUtils::MakeErrorJson(Result, TEXT("Missing required fields: name, packagePath, parentMaterial")); - } - - // Validate packagePath starts with /Game - if (!PackagePath.StartsWith(TEXT("/Game"))) - { - return MCPUtils::MakeErrorJson(Result, TEXT("packagePath must start with '/Game'")); - } - - // Check if asset already exists - FString FullAssetPath = PackagePath / Name; - if (UMCPAssetFinder::FindAsset(UMaterialInstanceConstant::StaticClass(), Name) || UMCPAssetFinder::FindAsset(UMaterialInstanceConstant::StaticClass(), FullAssetPath)) - { - return MCPUtils::MakeErrorJson(Result, 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 = UMCPAssetFinder::LoadAsset(ParentMaterialName, LoadError); - if (ParentMat) - { - ParentMaterial = ParentMat; - } - else - { - FString MILoadError; - UMaterialInstanceConstant* ParentMI = UMCPAssetFinder::LoadAsset(ParentMaterialName, MILoadError); - if (ParentMI) - { - ParentMaterial = ParentMI; - } - } - } - - if (!ParentMaterial) - { - // Also try LoadObject as a fallback with the raw path - ParentMaterial = LoadObject(nullptr, *ParentMaterialName); - } - - if (!ParentMaterial) - { - return MCPUtils::MakeErrorJson(Result, 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("AssetTools").Get(); - UMaterialInstanceConstantFactoryNew* Factory = NewObject(); - - UObject* NewAsset = AssetTools.CreateAsset(Name, PackagePath, UMaterialInstanceConstant::StaticClass(), Factory); - if (!NewAsset) - { - return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Failed to create Material Instance asset '%s' in '%s'"), *Name, *PackagePath)); - } - - UMaterialInstanceConstant* MI = Cast(NewAsset); - if (!MI) - { - return MCPUtils::MakeErrorJson(Result, TEXT("Created asset is not a UMaterialInstanceConstant")); - } - - // Set parent - MI->PreEditChange(nullptr); - MI->Parent = ParentMaterial; - MI->PostEditChange(); - - // Save - bool bSaved = MCPUtils::SaveGenericPackage(MI); - - - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Created Material Instance '%s' with parent '%s' (saved: %s)"), - *Name, *ParentMaterial->GetName(), bSaved ? TEXT("true") : TEXT("false")); - - 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); -} - -// ============================================================ -// HandleSetMaterialInstanceParameter — set a parameter override on an MI -// ============================================================ - -void FBlueprintMCPServer::HandleSetMaterialInstanceParameter(const FJsonObject* Json, FJsonObject* Result) -{ - FString MIName = Json->GetStringField(TEXT("materialInstance")); - FString ParamName = Json->GetStringField(TEXT("parameterName")); - - if (MIName.IsEmpty() || ParamName.IsEmpty()) - { - return MCPUtils::MakeErrorJson(Result, TEXT("Missing required fields: materialInstance, parameterName")); - } - - if (!Json->HasField(TEXT("value"))) - { - return MCPUtils::MakeErrorJson(Result, TEXT("Missing required field: value")); - } - - bool bDryRun = false; - if (Json->HasField(TEXT("dryRun"))) - { - bDryRun = Json->GetBoolField(TEXT("dryRun")); - } - - // Load the Material Instance - UMaterialInstanceConstant* MI = UMCPAssetFinder::LoadAsset(MIName, Result); - if (!MI) return; - - // 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(Expr)) - { - if (SP->ParameterName.ToString() == ParamName) - { - TypeStr = TEXT("scalar"); - break; - } - } - else if (auto* VP = Cast(Expr)) - { - if (VP->ParameterName.ToString() == ParamName) - { - TypeStr = TEXT("vector"); - break; - } - } - else if (auto* TP = Cast(Expr)) - { - if (TP->ParameterName.ToString() == ParamName) - { - TypeStr = TEXT("texture"); - break; - } - } - else if (auto* SSP = Cast(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(ParentMat); - if (ParentMI) - { - ParentMat = ParentMI->Parent; - } - else - { - break; - } - } - } - - if (TypeStr.IsEmpty()) - { - return MCPUtils::MakeErrorJson(Result, 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* ValueObj = nullptr; - if (!Json->TryGetObjectField(TEXT("value"), ValueObj) || !ValueObj || !(*ValueObj).IsValid()) - { - return MCPUtils::MakeErrorJson(Result, 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 MCPUtils::MakeErrorJson(Result, TEXT("For texture parameters, 'value' must be a texture asset path string.")); - } - - UTexture* TextureObj = LoadObject(nullptr, *TexturePath); - if (!TextureObj) - { - return MCPUtils::MakeErrorJson(Result, 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 MCPUtils::MakeErrorJson(Result, FString::Printf( - TEXT("Unknown parameter type '%s'. Valid types: scalar, vector, texture, staticSwitch"), - *TypeStr)); - } - - if (!bDryRun) - { - MI->PreEditChange(nullptr); - MI->PostEditChange(); - MI->MarkPackageDirty(); - MCPUtils::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); - - 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); - } -} - -// ============================================================ -// HandleGetMaterialInstanceParameters — list all parameters on an MI -// ============================================================ - -void FBlueprintMCPServer::HandleGetMaterialInstanceParameters(const FJsonObject* Json, FJsonObject* Result) -{ - FString NameParam = Json->GetStringField(TEXT("name")); - if (NameParam.IsEmpty()) - { - return MCPUtils::MakeErrorJson(Result, TEXT("Missing required query parameter: name")); - } - - UMaterialInstanceConstant* MI = UMCPAssetFinder::LoadAsset(NameParam, Result); - if (!MI) return; - - 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> ParentChainArr; - { - UMaterialInterface* Current = MI->Parent; - while (Current) - { - TSharedRef ParentObj = MakeShared(); - ParentObj->SetStringField(TEXT("name"), Current->GetName()); - ParentObj->SetStringField(TEXT("path"), Current->GetPathName()); - ParentObj->SetStringField(TEXT("class"), Current->GetClass()->GetName()); - ParentChainArr.Add(MakeShared(ParentObj)); - - UMaterialInstanceConstant* ParentMI = Cast(Current); - if (ParentMI) - { - Current = ParentMI->Parent; - } - else - { - break; // Reached the root Material - } - } - } - Result->SetArrayField(TEXT("parentChain"), ParentChainArr); - - // Scalar parameters - TArray> ScalarArr; - for (const FScalarParameterValue& Param : MI->ScalarParameterValues) - { - TSharedRef PObj = MakeShared(); - 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(PObj)); - } - Result->SetArrayField(TEXT("scalarParameters"), ScalarArr); - - // Vector parameters - TArray> VectorArr; - for (const FVectorParameterValue& Param : MI->VectorParameterValues) - { - TSharedRef PObj = MakeShared(); - 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(PObj)); - } - Result->SetArrayField(TEXT("vectorParameters"), VectorArr); - - // Texture parameters - TArray> TextureArr; - for (const FTextureParameterValue& Param : MI->TextureParameterValues) - { - TSharedRef PObj = MakeShared(); - 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(PObj)); - } - Result->SetArrayField(TEXT("textureParameters"), TextureArr); - - // Static switch parameters - TArray> StaticSwitchArr; - { - FStaticParameterSet StaticParams; - MI->GetStaticParameterValues(StaticParams); - - for (const FStaticSwitchParameter& Param : StaticParams.StaticSwitchParameters) - { - TSharedRef PObj = MakeShared(); - PObj->SetStringField(TEXT("name"), Param.ParameterInfo.Name.ToString()); - PObj->SetBoolField(TEXT("value"), Param.Value); - PObj->SetBoolField(TEXT("isOverridden"), Param.bOverride); - StaticSwitchArr.Add(MakeShared(PObj)); - } - } - Result->SetArrayField(TEXT("staticSwitchParameters"), StaticSwitchArr); - - // Also report inherited parameters from the parent material for discoverability - TArray> InheritedScalarArr; - TArray> InheritedVectorArr; - TArray> InheritedTextureArr; - TArray> InheritedStaticSwitchArr; - { - UMaterial* BaseMat = MI->GetMaterial(); - if (BaseMat) - { - // Collect names of already-overridden parameters for filtering - TSet OverriddenScalars; - for (const FScalarParameterValue& P : MI->ScalarParameterValues) - { - OverriddenScalars.Add(P.ParameterInfo.Name.ToString()); - } - TSet OverriddenVectors; - for (const FVectorParameterValue& P : MI->VectorParameterValues) - { - OverriddenVectors.Add(P.ParameterInfo.Name.ToString()); - } - TSet OverriddenTextures; - for (const FTextureParameterValue& P : MI->TextureParameterValues) - { - OverriddenTextures.Add(P.ParameterInfo.Name.ToString()); - } - TSet 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(Expr)) - { - if (!OverriddenScalars.Contains(SP->ParameterName.ToString())) - { - TSharedRef PObj = MakeShared(); - PObj->SetStringField(TEXT("name"), SP->ParameterName.ToString()); - PObj->SetNumberField(TEXT("defaultValue"), SP->DefaultValue); - PObj->SetBoolField(TEXT("isOverridden"), false); - InheritedScalarArr.Add(MakeShared(PObj)); - } - } - else if (auto* VP = Cast(Expr)) - { - if (!OverriddenVectors.Contains(VP->ParameterName.ToString())) - { - TSharedRef PObj = MakeShared(); - 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(PObj)); - } - } - else if (auto* TP = Cast(Expr)) - { - if (!OverriddenTextures.Contains(TP->ParameterName.ToString())) - { - TSharedRef PObj = MakeShared(); - 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(PObj)); - } - } - else if (auto* SSP = Cast(Expr)) - { - if (!OverriddenStaticSwitches.Contains(SSP->ParameterName.ToString())) - { - TSharedRef PObj = MakeShared(); - PObj->SetStringField(TEXT("name"), SSP->ParameterName.ToString()); - PObj->SetBoolField(TEXT("defaultValue"), SSP->DefaultValue); - PObj->SetBoolField(TEXT("isOverridden"), false); - InheritedStaticSwitchArr.Add(MakeShared(PObj)); - } - } - } - } - } - - // Merge inherited (non-overridden) params into the arrays - for (const TSharedPtr& V : InheritedScalarArr) - { - ScalarArr.Add(V); - } - for (const TSharedPtr& V : InheritedVectorArr) - { - VectorArr.Add(V); - } - for (const TSharedPtr& V : InheritedTextureArr) - { - TextureArr.Add(V); - } - for (const TSharedPtr& 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); -} - -// ============================================================ -// HandleReparentMaterialInstance — change parent of an MI -// ============================================================ - -void FBlueprintMCPServer::HandleReparentMaterialInstance(const FJsonObject* Json, FJsonObject* Result) -{ - FString MIName = Json->GetStringField(TEXT("materialInstance")); - FString NewParentName = Json->GetStringField(TEXT("newParent")); - - if (MIName.IsEmpty() || NewParentName.IsEmpty()) - { - return MCPUtils::MakeErrorJson(Result, TEXT("Missing required fields: materialInstance, newParent")); - } - - bool bDryRun = false; - if (Json->HasField(TEXT("dryRun"))) - { - bDryRun = Json->GetBoolField(TEXT("dryRun")); - } - - // Load the Material Instance - UMaterialInstanceConstant* MI = UMCPAssetFinder::LoadAsset(MIName, Result); - if (!MI) return; - - // 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 = UMCPAssetFinder::LoadAsset(NewParentName, MatLoadError); - if (NewParentMat) - { - NewParent = NewParentMat; - } - else - { - FString MILoadError; - UMaterialInstanceConstant* NewParentMI = UMCPAssetFinder::LoadAsset(NewParentName, MILoadError); - if (NewParentMI) - { - NewParent = NewParentMI; - } - } - } - - if (!NewParent) - { - // Try LoadObject as a fallback - NewParent = LoadObject(nullptr, *NewParentName); - } - - if (!NewParent) - { - return MCPUtils::MakeErrorJson(Result, 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 MCPUtils::MakeErrorJson(Result, FString::Printf( - TEXT("Cannot reparent '%s' to '%s' — this would create a circular parent chain."), - *MIName, *NewParentName)); - } - UMaterialInstanceConstant* CheckMI = Cast(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 = MCPUtils::SaveGenericPackage(MI); - - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Reparented Material Instance '%s' (saved: %s)"), - *MIName, bSaved ? TEXT("true") : TEXT("false")); - } - - 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); - } -} diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_MaterialInstance.h b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_MaterialInstance.h new file mode 100644 index 00000000..ef026955 --- /dev/null +++ b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_MaterialInstance.h @@ -0,0 +1,733 @@ +#pragma once + +#include "CoreMinimal.h" +#include "MCPHandler.h" +#include "MCPAssetFinder.h" +#include "MCPUtils.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 "Engine/Texture.h" +#include "MCPHandlers_MaterialInstance.generated.h" + +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + +UCLASS(meta=(ToolName="create_material_instance_asset")) +class UMCPHandler_CreateMaterialInstance : public UObject, public IMCPHandler +{ + GENERATED_BODY() + +public: + UPROPERTY(meta=(Description="Name for the new Material Instance asset")) + FString Name; + + UPROPERTY(meta=(Description="Package path where the asset will be created (must start with /Game)")) + FString PackagePath; + + UPROPERTY(meta=(Description="Parent material name or path (Material or Material Instance)")) + FString ParentMaterial; + + virtual FString GetDescription() const override + { + return TEXT("Create a new Material Instance Constant asset with a specified parent material."); + } + + virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override + { + // Validate packagePath starts with /Game + if (!PackagePath.StartsWith(TEXT("/Game"))) + { + return MCPUtils::MakeErrorJson(Result, TEXT("packagePath must start with '/Game'")); + } + + // Check if asset already exists + FString FullAssetPath = PackagePath / Name; + if (UMCPAssetFinder::FindAsset(UMaterialInstanceConstant::StaticClass(), Name) || UMCPAssetFinder::FindAsset(UMaterialInstanceConstant::StaticClass(), FullAssetPath)) + { + return MCPUtils::MakeErrorJson(Result, 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* ParentMaterialObj = nullptr; + { + FString LoadError; + UMaterial* ParentMat = UMCPAssetFinder::LoadAsset(ParentMaterial, LoadError); + if (ParentMat) + { + ParentMaterialObj = ParentMat; + } + else + { + FString MILoadError; + UMaterialInstanceConstant* ParentMI = UMCPAssetFinder::LoadAsset(ParentMaterial, MILoadError); + if (ParentMI) + { + ParentMaterialObj = ParentMI; + } + } + } + + if (!ParentMaterialObj) + { + // Also try LoadObject as a fallback with the raw path + ParentMaterialObj = LoadObject(nullptr, *ParentMaterial); + } + + if (!ParentMaterialObj) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf( + TEXT("Parent material '%s' not found. Provide a Material or Material Instance name/path."), + *ParentMaterial)); + } + + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Creating Material Instance '%s' in '%s' with parent '%s'"), + *Name, *PackagePath, *ParentMaterialObj->GetName()); + + // Create via factory + AssetTools + IAssetTools& AssetTools = FModuleManager::LoadModuleChecked("AssetTools").Get(); + UMaterialInstanceConstantFactoryNew* Factory = NewObject(); + + UObject* NewAsset = AssetTools.CreateAsset(Name, PackagePath, UMaterialInstanceConstant::StaticClass(), Factory); + if (!NewAsset) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Failed to create Material Instance asset '%s' in '%s'"), *Name, *PackagePath)); + } + + UMaterialInstanceConstant* MI = Cast(NewAsset); + if (!MI) + { + return MCPUtils::MakeErrorJson(Result, TEXT("Created asset is not a UMaterialInstanceConstant")); + } + + // Set parent + MI->PreEditChange(nullptr); + MI->Parent = ParentMaterialObj; + MI->PostEditChange(); + + // Save + bool bSaved = MCPUtils::SaveGenericPackage(MI); + + + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Created Material Instance '%s' with parent '%s' (saved: %s)"), + *Name, *ParentMaterialObj->GetName(), bSaved ? TEXT("true") : TEXT("false")); + + Result->SetBoolField(TEXT("success"), true); + Result->SetStringField(TEXT("name"), Name); + Result->SetStringField(TEXT("path"), MI->GetPathName()); + Result->SetStringField(TEXT("parent"), ParentMaterialObj->GetPathName()); + Result->SetBoolField(TEXT("saved"), bSaved); + } +}; + +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + +UCLASS(meta=(ToolName="set_material_instance_parameter")) +class UMCPHandler_SetMaterialInstanceParameter : public UObject, public IMCPHandler +{ + GENERATED_BODY() + +public: + UPROPERTY(meta=(Description="Material Instance name or path")) + FString MaterialInstance; + + UPROPERTY(meta=(Description="Parameter name to set")) + FString ParameterName; + + UPROPERTY(meta=(Description="Value to set (number for scalar, object with r/g/b/a for vector, string path for texture, bool for staticSwitch)")) + FMCPJsonObject Value; + + UPROPERTY(meta=(Optional, Description="Parameter type: scalar, vector, texture, staticSwitch. Auto-detected from parent if omitted.")) + FString Type; + + UPROPERTY(meta=(Optional, Description="If true, validate without applying changes")) + bool DryRun = false; + + virtual FString GetDescription() const override + { + return TEXT("Set a parameter override on a Material Instance. " + "Supports scalar, vector, texture, and static switch parameter types."); + } + + virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override + { + if (!Json->HasField(TEXT("value"))) + { + return MCPUtils::MakeErrorJson(Result, TEXT("Missing required field: value")); + } + + // Load the Material Instance + UMaterialInstanceConstant* MI = UMCPAssetFinder::LoadAsset(MaterialInstance, Result); + if (!MI) return; + + // Determine the parameter type — explicit or auto-detect from parent + FString TypeStr = 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(Expr)) + { + if (SP->ParameterName.ToString() == ParameterName) + { + TypeStr = TEXT("scalar"); + break; + } + } + else if (auto* VP = Cast(Expr)) + { + if (VP->ParameterName.ToString() == ParameterName) + { + TypeStr = TEXT("vector"); + break; + } + } + else if (auto* TP = Cast(Expr)) + { + if (TP->ParameterName.ToString() == ParameterName) + { + TypeStr = TEXT("texture"); + break; + } + } + else if (auto* SSP = Cast(Expr)) + { + if (SSP->ParameterName.ToString() == ParameterName) + { + 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(ParentMat); + if (ParentMI) + { + ParentMat = ParentMI->Parent; + } + else + { + break; + } + } + } + + if (TypeStr.IsEmpty()) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf( + TEXT("Could not determine parameter type for '%s'. Specify the 'type' field explicitly (scalar, vector, texture, staticSwitch)."), + *ParameterName)); + } + + FString NewValueDescription; + FMaterialParameterInfo ParamInfo(*ParameterName); + + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: %s parameter '%s' (type=%s) on Material Instance '%s'"), + DryRun ? TEXT("[DRY RUN] Setting") : TEXT("Setting"), + *ParameterName, *TypeStr, *MaterialInstance); + + if (TypeStr.Equals(TEXT("scalar"), ESearchCase::IgnoreCase)) + { + // Scalar parameter — value is a number + double FloatValue = Json->GetNumberField(TEXT("value")); + + if (!DryRun) + { + 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* ValueObj = nullptr; + if (!Json->TryGetObjectField(TEXT("value"), ValueObj) || !ValueObj || !(*ValueObj).IsValid()) + { + return MCPUtils::MakeErrorJson(Result, 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 (!DryRun) + { + 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 MCPUtils::MakeErrorJson(Result, TEXT("For texture parameters, 'value' must be a texture asset path string.")); + } + + UTexture* TextureObj = LoadObject(nullptr, *TexturePath); + if (!TextureObj) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Could not load texture at path '%s'"), *TexturePath)); + } + + if (!DryRun) + { + 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 (!DryRun) + { + // Modify static parameters + FStaticParameterSet StaticParams; + MI->GetStaticParameterValues(StaticParams); + + bool bFound = false; + for (FStaticSwitchParameter& Param : StaticParams.StaticSwitchParameters) + { + if (Param.ParameterInfo.Name == FName(*ParameterName)) + { + Param.Value = bSwitchValue; + Param.bOverride = true; + bFound = true; + break; + } + } + + if (!bFound) + { + // Add new static switch parameter entry + FStaticSwitchParameter NewParam; + NewParam.ParameterInfo.Name = FName(*ParameterName); + NewParam.Value = bSwitchValue; + NewParam.bOverride = true; + StaticParams.StaticSwitchParameters.Add(NewParam); + } + + MI->UpdateStaticPermutation(StaticParams); + } + NewValueDescription = bSwitchValue ? TEXT("true") : TEXT("false"); + } + else + { + return MCPUtils::MakeErrorJson(Result, FString::Printf( + TEXT("Unknown parameter type '%s'. Valid types: scalar, vector, texture, staticSwitch"), + *TypeStr)); + } + + if (!DryRun) + { + MI->PreEditChange(nullptr); + MI->PostEditChange(); + MI->MarkPackageDirty(); + MCPUtils::SaveGenericPackage(MI); + } + + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: %s parameter '%s' = %s on '%s'"), + DryRun ? TEXT("[DRY RUN] Would set") : TEXT("Set"), + *ParameterName, *NewValueDescription, *MaterialInstance); + + Result->SetBoolField(TEXT("success"), true); + Result->SetStringField(TEXT("materialInstance"), MaterialInstance); + Result->SetStringField(TEXT("parameterName"), ParameterName); + Result->SetStringField(TEXT("type"), TypeStr); + Result->SetStringField(TEXT("newValue"), NewValueDescription); + if (DryRun) + { + Result->SetBoolField(TEXT("dryRun"), true); + } + } +}; + +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + +UCLASS(meta=(ToolName="dump_material_instance_parameters")) +class UMCPHandler_GetMaterialInstanceParameters : public UObject, public IMCPHandler +{ + GENERATED_BODY() + +public: + UPROPERTY(meta=(Description="Material Instance name or path to inspect")) + FString MaterialInstance; + + virtual FString GetDescription() const override + { + return TEXT("List all parameters on a Material Instance, including overridden and inherited parameters."); + } + + virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override + { + UMaterialInstanceConstant* MI = UMCPAssetFinder::LoadAsset(MaterialInstance, Result); + if (!MI) return; + + 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> ParentChainArr; + { + UMaterialInterface* Current = MI->Parent; + while (Current) + { + TSharedRef ParentObj = MakeShared(); + ParentObj->SetStringField(TEXT("name"), Current->GetName()); + ParentObj->SetStringField(TEXT("path"), Current->GetPathName()); + ParentObj->SetStringField(TEXT("class"), Current->GetClass()->GetName()); + ParentChainArr.Add(MakeShared(ParentObj)); + + UMaterialInstanceConstant* ParentMI = Cast(Current); + if (ParentMI) + { + Current = ParentMI->Parent; + } + else + { + break; // Reached the root Material + } + } + } + Result->SetArrayField(TEXT("parentChain"), ParentChainArr); + + // Scalar parameters + TArray> ScalarArr; + for (const FScalarParameterValue& Param : MI->ScalarParameterValues) + { + TSharedRef PObj = MakeShared(); + 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(PObj)); + } + Result->SetArrayField(TEXT("scalarParameters"), ScalarArr); + + // Vector parameters + TArray> VectorArr; + for (const FVectorParameterValue& Param : MI->VectorParameterValues) + { + TSharedRef PObj = MakeShared(); + 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(PObj)); + } + Result->SetArrayField(TEXT("vectorParameters"), VectorArr); + + // Texture parameters + TArray> TextureArr; + for (const FTextureParameterValue& Param : MI->TextureParameterValues) + { + TSharedRef PObj = MakeShared(); + 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(PObj)); + } + Result->SetArrayField(TEXT("textureParameters"), TextureArr); + + // Static switch parameters + TArray> StaticSwitchArr; + { + FStaticParameterSet StaticParams; + MI->GetStaticParameterValues(StaticParams); + + for (const FStaticSwitchParameter& Param : StaticParams.StaticSwitchParameters) + { + TSharedRef PObj = MakeShared(); + PObj->SetStringField(TEXT("name"), Param.ParameterInfo.Name.ToString()); + PObj->SetBoolField(TEXT("value"), Param.Value); + PObj->SetBoolField(TEXT("isOverridden"), Param.bOverride); + StaticSwitchArr.Add(MakeShared(PObj)); + } + } + Result->SetArrayField(TEXT("staticSwitchParameters"), StaticSwitchArr); + + // Also report inherited parameters from the parent material for discoverability + TArray> InheritedScalarArr; + TArray> InheritedVectorArr; + TArray> InheritedTextureArr; + TArray> InheritedStaticSwitchArr; + { + UMaterial* BaseMat = MI->GetMaterial(); + if (BaseMat) + { + // Collect names of already-overridden parameters for filtering + TSet OverriddenScalars; + for (const FScalarParameterValue& P : MI->ScalarParameterValues) + { + OverriddenScalars.Add(P.ParameterInfo.Name.ToString()); + } + TSet OverriddenVectors; + for (const FVectorParameterValue& P : MI->VectorParameterValues) + { + OverriddenVectors.Add(P.ParameterInfo.Name.ToString()); + } + TSet OverriddenTextures; + for (const FTextureParameterValue& P : MI->TextureParameterValues) + { + OverriddenTextures.Add(P.ParameterInfo.Name.ToString()); + } + TSet 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(Expr)) + { + if (!OverriddenScalars.Contains(SP->ParameterName.ToString())) + { + TSharedRef PObj = MakeShared(); + PObj->SetStringField(TEXT("name"), SP->ParameterName.ToString()); + PObj->SetNumberField(TEXT("defaultValue"), SP->DefaultValue); + PObj->SetBoolField(TEXT("isOverridden"), false); + InheritedScalarArr.Add(MakeShared(PObj)); + } + } + else if (auto* VP = Cast(Expr)) + { + if (!OverriddenVectors.Contains(VP->ParameterName.ToString())) + { + TSharedRef PObj = MakeShared(); + 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(PObj)); + } + } + else if (auto* TP = Cast(Expr)) + { + if (!OverriddenTextures.Contains(TP->ParameterName.ToString())) + { + TSharedRef PObj = MakeShared(); + 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(PObj)); + } + } + else if (auto* SSP = Cast(Expr)) + { + if (!OverriddenStaticSwitches.Contains(SSP->ParameterName.ToString())) + { + TSharedRef PObj = MakeShared(); + PObj->SetStringField(TEXT("name"), SSP->ParameterName.ToString()); + PObj->SetBoolField(TEXT("defaultValue"), SSP->DefaultValue); + PObj->SetBoolField(TEXT("isOverridden"), false); + InheritedStaticSwitchArr.Add(MakeShared(PObj)); + } + } + } + } + } + + // Merge inherited (non-overridden) params into the arrays + for (const TSharedPtr& V : InheritedScalarArr) + { + ScalarArr.Add(V); + } + for (const TSharedPtr& V : InheritedVectorArr) + { + VectorArr.Add(V); + } + for (const TSharedPtr& V : InheritedTextureArr) + { + TextureArr.Add(V); + } + for (const TSharedPtr& 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); + } +}; + +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + +UCLASS(meta=(ToolName="reparent_material_instance")) +class UMCPHandler_ReparentMaterialInstance : public UObject, public IMCPHandler +{ + GENERATED_BODY() + +public: + UPROPERTY(meta=(Description="Material Instance name or path to reparent")) + FString MaterialInstance; + + UPROPERTY(meta=(Description="New parent material name or path (Material or Material Instance)")) + FString NewParent; + + UPROPERTY(meta=(Optional, Description="If true, validate without applying changes")) + bool DryRun = false; + + virtual FString GetDescription() const override + { + return TEXT("Change the parent material of a Material Instance. " + "Validates against circular parent chains."); + } + + virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override + { + // Load the Material Instance + UMaterialInstanceConstant* MI = UMCPAssetFinder::LoadAsset(MaterialInstance, Result); + if (!MI) return; + + // Capture old parent + FString OldParentPath = MI->Parent ? MI->Parent->GetPathName() : TEXT("None"); + + // Load new parent — try as Material first, then as Material Instance + UMaterialInterface* NewParentObj = nullptr; + { + FString MatLoadError; + UMaterial* NewParentMat = UMCPAssetFinder::LoadAsset(NewParent, MatLoadError); + if (NewParentMat) + { + NewParentObj = NewParentMat; + } + else + { + FString MILoadError; + UMaterialInstanceConstant* NewParentMI = UMCPAssetFinder::LoadAsset(NewParent, MILoadError); + if (NewParentMI) + { + NewParentObj = NewParentMI; + } + } + } + + if (!NewParentObj) + { + // Try LoadObject as a fallback + NewParentObj = LoadObject(nullptr, *NewParent); + } + + if (!NewParentObj) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf( + TEXT("New parent material '%s' not found. Provide a Material or Material Instance name/path."), + *NewParent)); + } + + // Prevent circular parenting — check if NewParent is this MI or has this MI in its chain + { + UMaterialInterface* Check = NewParentObj; + while (Check) + { + if (Check == MI) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf( + TEXT("Cannot reparent '%s' to '%s' — this would create a circular parent chain."), + *MaterialInstance, *NewParent)); + } + UMaterialInstanceConstant* CheckMI = Cast(Check); + if (CheckMI) + { + Check = CheckMI->Parent; + } + else + { + break; + } + } + } + + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: %s Material Instance '%s': parent '%s' -> '%s'"), + DryRun ? TEXT("[DRY RUN] Reparenting") : TEXT("Reparenting"), + *MaterialInstance, *OldParentPath, *NewParentObj->GetPathName()); + + if (!DryRun) + { + MI->PreEditChange(nullptr); + MI->Parent = NewParentObj; + MI->PostEditChange(); + + bool bSaved = MCPUtils::SaveGenericPackage(MI); + + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Reparented Material Instance '%s' (saved: %s)"), + *MaterialInstance, bSaved ? TEXT("true") : TEXT("false")); + } + + Result->SetBoolField(TEXT("success"), true); + Result->SetStringField(TEXT("materialInstance"), MaterialInstance); + Result->SetStringField(TEXT("oldParent"), OldParentPath); + Result->SetStringField(TEXT("newParent"), NewParentObj->GetPathName()); + if (DryRun) + { + Result->SetBoolField(TEXT("dryRun"), true); + } + } +}; diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_MaterialMutation.cpp b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_MaterialMutation.cpp deleted file mode 100644 index 515aba96..00000000 --- a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_MaterialMutation.cpp +++ /dev/null @@ -1,1954 +0,0 @@ -#include "MCPAssetFinder.h" -#include "MCPServer.h" -#include "MCPUtils.h" -#include "Materials/Material.h" -#include "MaterialDomain.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/MaterialGraphSchema.h" -#include "Factories/MaterialFactoryNew.h" -#include "Factories/MaterialFunctionFactoryNew.h" -#include "AssetToolsModule.h" -#include "IAssetTools.h" -#include "AssetRegistry/AssetRegistryModule.h" -#include "EdGraph/EdGraph.h" -#include "EdGraph/EdGraphNode.h" -#include "Serialization/JsonReader.h" -#include "Serialization/JsonWriter.h" -#include "Serialization/JsonSerializer.h" -#include "Misc/Guid.h" -#include "Misc/FileHelper.h" -#include "Misc/Paths.h" -#include "UObject/SavePackage.h" -#include "UObject/UObjectIterator.h" -#include "Kismet2/BlueprintEditorUtils.h" - -// SEH wrapper defined in BlueprintMCPServer.cpp — catches crashes from abstract/invalid expression classes. -// Wraps the entire creation + registration + PostEditChange flow so that if the expression crashes -// (e.g. UMaterialExpressionParameter), it cleans up and returns -1 instead of terminating the process. -#if PLATFORM_WINDOWS -extern int32 TryAddMaterialExpressionSEH( - UObject* Owner, UClass* ExprClass, UMaterial* Material, UMaterialFunction* MatFunc, - int32 PosX, int32 PosY, UMaterialExpression** OutExpr); -#endif - -// ============================================================ -// Phase 2: Material Mutations -// ============================================================ - -// ============================================================ -// HandleCreateMaterial — create a new UMaterial asset -// ============================================================ - -void FBlueprintMCPServer::HandleCreateMaterial(const FJsonObject* Json, FJsonObject* Result) -{ - FString Name = Json->GetStringField(TEXT("name")); - FString PackagePath = Json->GetStringField(TEXT("packagePath")); - - if (Name.IsEmpty() || PackagePath.IsEmpty()) - { - return MCPUtils::MakeErrorJson(Result, TEXT("Missing required fields: name, packagePath")); - } - - if (!PackagePath.StartsWith(TEXT("/Game"))) - { - return MCPUtils::MakeErrorJson(Result, TEXT("packagePath must start with '/Game'")); - } - - // Check if asset already exists - FString FullAssetPath = PackagePath / Name; - if (UMCPAssetFinder::FindAsset(UMaterial::StaticClass(), Name) || UMCPAssetFinder::FindAsset(UMaterial::StaticClass(), FullAssetPath)) - { - return MCPUtils::MakeErrorJson(Result, FString::Printf( - TEXT("Material '%s' already exists. Use a different name or delete the existing asset first."), - *Name)); - } - - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Creating Material '%s' in '%s'"), *Name, *PackagePath); - - // Create via IAssetTools + factory - IAssetTools& AssetTools = FModuleManager::LoadModuleChecked("AssetTools").Get(); - UMaterialFactoryNew* Factory = NewObject(); - UObject* NewAsset = AssetTools.CreateAsset(Name, PackagePath, UMaterial::StaticClass(), Factory); - - if (!NewAsset) - { - return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Failed to create Material '%s' in '%s'"), *Name, *PackagePath)); - } - - UMaterial* Material = Cast(NewAsset); - if (!Material) - { - return MCPUtils::MakeErrorJson(Result, TEXT("Created asset is not a UMaterial")); - } - - // Apply optional properties - FString DomainStr; - Json->TryGetStringField(TEXT("domain"), DomainStr); - - FString BlendModeStr; - Json->TryGetStringField(TEXT("blendMode"), BlendModeStr); - - bool bTwoSided = false; - bool bHasTwoSided = Json->TryGetBoolField(TEXT("twoSided"), bTwoSided); - - Material->PreEditChange(nullptr); - - // Parse domain - if (!DomainStr.IsEmpty()) - { - if (DomainStr == TEXT("Surface")) - Material->MaterialDomain = MD_Surface; - else if (DomainStr == TEXT("DeferredDecal")) - Material->MaterialDomain = MD_DeferredDecal; - else if (DomainStr == TEXT("LightFunction")) - Material->MaterialDomain = MD_LightFunction; - else if (DomainStr == TEXT("Volume")) - Material->MaterialDomain = MD_Volume; - else if (DomainStr == TEXT("PostProcess")) - Material->MaterialDomain = MD_PostProcess; - else if (DomainStr == TEXT("UI")) - Material->MaterialDomain = MD_UI; - } - - // Parse blend mode - if (!BlendModeStr.IsEmpty()) - { - if (BlendModeStr == TEXT("Opaque")) - Material->BlendMode = BLEND_Opaque; - else if (BlendModeStr == TEXT("Masked")) - Material->BlendMode = BLEND_Masked; - else if (BlendModeStr == TEXT("Translucent")) - Material->BlendMode = BLEND_Translucent; - else if (BlendModeStr == TEXT("Additive")) - Material->BlendMode = BLEND_Additive; - else if (BlendModeStr == TEXT("Modulate")) - Material->BlendMode = BLEND_Modulate; - } - - if (bHasTwoSided) - { - Material->TwoSided = bTwoSided; - } - - Material->PostEditChange(); - - // Save - bool bSaved = MCPUtils::SaveMaterialPackage(Material); - - - // Map domain back to string for response - auto DomainToString = [](EMaterialDomain Domain) -> FString - { - switch (Domain) - { - case MD_Surface: return TEXT("Surface"); - case MD_DeferredDecal: return TEXT("DeferredDecal"); - case MD_LightFunction: return TEXT("LightFunction"); - case MD_Volume: return TEXT("Volume"); - case MD_PostProcess: return TEXT("PostProcess"); - case MD_UI: return TEXT("UI"); - default: return TEXT("Surface"); - } - }; - - auto BlendModeToString = [](EBlendMode Mode) -> FString - { - switch (Mode) - { - case BLEND_Opaque: return TEXT("Opaque"); - case BLEND_Masked: return TEXT("Masked"); - case BLEND_Translucent: return TEXT("Translucent"); - case BLEND_Additive: return TEXT("Additive"); - case BLEND_Modulate: return TEXT("Modulate"); - default: return TEXT("Opaque"); - } - }; - - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Created Material '%s' (saved: %s)"), - *Name, bSaved ? TEXT("true") : TEXT("false")); - - Result->SetBoolField(TEXT("success"), true); - Result->SetStringField(TEXT("name"), Name); - Result->SetStringField(TEXT("path"), Material->GetPathName()); - Result->SetStringField(TEXT("domain"), DomainToString(Material->MaterialDomain)); - Result->SetStringField(TEXT("blendMode"), BlendModeToString(Material->BlendMode)); - Result->SetBoolField(TEXT("twoSided"), Material->TwoSided != 0); - Result->SetBoolField(TEXT("saved"), bSaved); -} - -// ============================================================ -// HandleSetMaterialProperty — set a top-level material property -// ============================================================ - -void FBlueprintMCPServer::HandleSetMaterialProperty(const FJsonObject* Json, FJsonObject* Result) -{ - FString MaterialName = Json->GetStringField(TEXT("material")); - FString Property = Json->GetStringField(TEXT("property")); - - if (MaterialName.IsEmpty() || Property.IsEmpty()) - { - return MCPUtils::MakeErrorJson(Result, TEXT("Missing required fields: material, property")); - } - - if (!Json->HasField(TEXT("value"))) - { - return MCPUtils::MakeErrorJson(Result, TEXT("Missing required field: value")); - } - - bool bDryRun = false; - Json->TryGetBoolField(TEXT("dryRun"), bDryRun); - - // Load material - UMaterial* Material = UMCPAssetFinder::LoadAsset(MaterialName, Result); - if (!Material) return; - - FString OldValue; - FString NewValue; - - // Helper lambdas for converting enum values to strings - auto DomainToString = [](EMaterialDomain Domain) -> FString - { - switch (Domain) - { - case MD_Surface: return TEXT("Surface"); - case MD_DeferredDecal: return TEXT("DeferredDecal"); - case MD_LightFunction: return TEXT("LightFunction"); - case MD_Volume: return TEXT("Volume"); - case MD_PostProcess: return TEXT("PostProcess"); - case MD_UI: return TEXT("UI"); - default: return TEXT("Unknown"); - } - }; - - auto BlendModeToString = [](EBlendMode Mode) -> FString - { - switch (Mode) - { - case BLEND_Opaque: return TEXT("Opaque"); - case BLEND_Masked: return TEXT("Masked"); - case BLEND_Translucent: return TEXT("Translucent"); - case BLEND_Additive: return TEXT("Additive"); - case BLEND_Modulate: return TEXT("Modulate"); - default: return TEXT("Unknown"); - } - }; - - auto ShadingModelToString = [](EMaterialShadingModel Model) -> FString - { - switch (Model) - { - case MSM_Unlit: return TEXT("Unlit"); - case MSM_DefaultLit: return TEXT("DefaultLit"); - case MSM_Subsurface: return TEXT("Subsurface"); - case MSM_PreintegratedSkin: return TEXT("PreintegratedSkin"); - case MSM_ClearCoat: return TEXT("ClearCoat"); - case MSM_SubsurfaceProfile: return TEXT("SubsurfaceProfile"); - case MSM_TwoSidedFoliage: return TEXT("TwoSidedFoliage"); - case MSM_Hair: return TEXT("Hair"); - case MSM_Cloth: return TEXT("Cloth"); - case MSM_Eye: return TEXT("Eye"); - default: return TEXT("DefaultLit"); - } - }; - - if (Property == TEXT("domain")) - { - FString ValueStr = Json->GetStringField(TEXT("value")); - OldValue = DomainToString(Material->MaterialDomain); - - EMaterialDomain NewDomain = Material->MaterialDomain; - if (ValueStr == TEXT("Surface")) NewDomain = MD_Surface; - else if (ValueStr == TEXT("DeferredDecal")) NewDomain = MD_DeferredDecal; - else if (ValueStr == TEXT("LightFunction")) NewDomain = MD_LightFunction; - else if (ValueStr == TEXT("Volume")) NewDomain = MD_Volume; - else if (ValueStr == TEXT("PostProcess")) NewDomain = MD_PostProcess; - else if (ValueStr == TEXT("UI")) NewDomain = MD_UI; - else - { - return MCPUtils::MakeErrorJson(Result, FString::Printf( - TEXT("Invalid domain '%s'. Valid values: Surface, DeferredDecal, LightFunction, Volume, PostProcess, UI"), - *ValueStr)); - } - - NewValue = ValueStr; - - if (!bDryRun) - { - Material->PreEditChange(nullptr); - Material->MaterialDomain = NewDomain; - Material->PostEditChange(); - } - } - else if (Property == TEXT("blendMode")) - { - FString ValueStr = Json->GetStringField(TEXT("value")); - OldValue = BlendModeToString(Material->BlendMode); - - EBlendMode NewBlend = Material->BlendMode; - if (ValueStr == TEXT("Opaque")) NewBlend = BLEND_Opaque; - else if (ValueStr == TEXT("Masked")) NewBlend = BLEND_Masked; - else if (ValueStr == TEXT("Translucent")) NewBlend = BLEND_Translucent; - else if (ValueStr == TEXT("Additive")) NewBlend = BLEND_Additive; - else if (ValueStr == TEXT("Modulate")) NewBlend = BLEND_Modulate; - else - { - return MCPUtils::MakeErrorJson(Result, FString::Printf( - TEXT("Invalid blendMode '%s'. Valid values: Opaque, Masked, Translucent, Additive, Modulate"), - *ValueStr)); - } - - NewValue = ValueStr; - - if (!bDryRun) - { - Material->PreEditChange(nullptr); - Material->BlendMode = NewBlend; - Material->PostEditChange(); - } - } - else if (Property == TEXT("twoSided")) - { - bool bValue = Json->GetBoolField(TEXT("value")); - OldValue = Material->TwoSided ? TEXT("true") : TEXT("false"); - NewValue = bValue ? TEXT("true") : TEXT("false"); - - if (!bDryRun) - { - Material->PreEditChange(nullptr); - Material->TwoSided = bValue ? 1 : 0; - Material->PostEditChange(); - } - } - else if (Property == TEXT("shadingModel")) - { - FString ValueStr = Json->GetStringField(TEXT("value")); - OldValue = ShadingModelToString(Material->GetShadingModels().GetFirstShadingModel()); - - EMaterialShadingModel NewModel = MSM_DefaultLit; - if (ValueStr == TEXT("Unlit")) NewModel = MSM_Unlit; - else if (ValueStr == TEXT("DefaultLit")) NewModel = MSM_DefaultLit; - else if (ValueStr == TEXT("Subsurface")) NewModel = MSM_Subsurface; - else if (ValueStr == TEXT("PreintegratedSkin")) NewModel = MSM_PreintegratedSkin; - else if (ValueStr == TEXT("ClearCoat")) NewModel = MSM_ClearCoat; - else if (ValueStr == TEXT("SubsurfaceProfile")) NewModel = MSM_SubsurfaceProfile; - else if (ValueStr == TEXT("TwoSidedFoliage")) NewModel = MSM_TwoSidedFoliage; - else if (ValueStr == TEXT("Hair")) NewModel = MSM_Hair; - else if (ValueStr == TEXT("Cloth")) NewModel = MSM_Cloth; - else if (ValueStr == TEXT("Eye")) NewModel = MSM_Eye; - else - { - return MCPUtils::MakeErrorJson(Result, FString::Printf( - TEXT("Invalid shadingModel '%s'. Valid values: Unlit, DefaultLit, Subsurface, PreintegratedSkin, ClearCoat, SubsurfaceProfile, TwoSidedFoliage, Hair, Cloth, Eye"), - *ValueStr)); - } - - NewValue = ValueStr; - - if (!bDryRun) - { - Material->PreEditChange(nullptr); - Material->SetShadingModel(NewModel); - Material->PostEditChange(); - } - } - else if (Property == TEXT("opacity") || Property == TEXT("opacityMaskClipValue")) - { - double OpacityValue = Json->GetNumberField(TEXT("value")); - OldValue = FString::Printf(TEXT("%f"), Material->OpacityMaskClipValue); - NewValue = FString::Printf(TEXT("%f"), OpacityValue); - - if (!bDryRun) - { - Material->PreEditChange(nullptr); - Material->OpacityMaskClipValue = (float)OpacityValue; - Material->PostEditChange(); - } - } - else if (Property == TEXT("bUsedWithSkeletalMesh")) - { - bool bValue = Json->GetBoolField(TEXT("value")); - OldValue = Material->bUsedWithSkeletalMesh ? TEXT("true") : TEXT("false"); - NewValue = bValue ? TEXT("true") : TEXT("false"); - - if (!bDryRun) - { - Material->PreEditChange(nullptr); - Material->bUsedWithSkeletalMesh = bValue ? 1 : 0; - Material->PostEditChange(); - } - } - else if (Property == TEXT("bUsedWithMorphTargets")) - { - bool bValue = Json->GetBoolField(TEXT("value")); - OldValue = Material->bUsedWithMorphTargets ? TEXT("true") : TEXT("false"); - NewValue = bValue ? TEXT("true") : TEXT("false"); - - if (!bDryRun) - { - Material->PreEditChange(nullptr); - Material->bUsedWithMorphTargets = bValue ? 1 : 0; - Material->PostEditChange(); - } - } - else if (Property == TEXT("bUsedWithNiagaraSprites")) - { - bool bValue = Json->GetBoolField(TEXT("value")); - OldValue = Material->bUsedWithNiagaraSprites ? TEXT("true") : TEXT("false"); - NewValue = bValue ? TEXT("true") : TEXT("false"); - - if (!bDryRun) - { - Material->PreEditChange(nullptr); - Material->bUsedWithNiagaraSprites = bValue ? 1 : 0; - Material->PostEditChange(); - } - } - else if (Property == TEXT("ditheredLODTransition") || Property == TEXT("DitheredLODTransition")) - { - bool bValue = Json->GetBoolField(TEXT("value")); - OldValue = Material->DitheredLODTransition ? TEXT("true") : TEXT("false"); - NewValue = bValue ? TEXT("true") : TEXT("false"); - - if (!bDryRun) - { - Material->PreEditChange(nullptr); - Material->DitheredLODTransition = bValue ? 1 : 0; - Material->PostEditChange(); - } - } - else if (Property == TEXT("bAllowNegativeEmissiveColor")) - { - bool bValue = Json->GetBoolField(TEXT("value")); - OldValue = Material->bAllowNegativeEmissiveColor ? TEXT("true") : TEXT("false"); - NewValue = bValue ? TEXT("true") : TEXT("false"); - - if (!bDryRun) - { - Material->PreEditChange(nullptr); - Material->bAllowNegativeEmissiveColor = bValue ? 1 : 0; - Material->PostEditChange(); - } - } - else - { - return MCPUtils::MakeErrorJson(Result, FString::Printf( - TEXT("Unknown property '%s'. Valid properties: domain, blendMode, twoSided, shadingModel, opacity, " - "opacityMaskClipValue, bUsedWithSkeletalMesh, bUsedWithMorphTargets, bUsedWithNiagaraSprites, " - "ditheredLODTransition, bAllowNegativeEmissiveColor"), - *Property)); - } - - // Save if not dry run - bool bSaved = false; - if (!bDryRun) - { - bSaved = MCPUtils::SaveMaterialPackage(Material); - } - - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: %sSet material property '%s' on '%s': '%s' -> '%s'"), - bDryRun ? TEXT("[DRY RUN] ") : TEXT(""), - *Property, *MaterialName, *OldValue, *NewValue); - - Result->SetBoolField(TEXT("success"), true); - Result->SetStringField(TEXT("material"), Material->GetName()); - Result->SetStringField(TEXT("property"), Property); - Result->SetStringField(TEXT("oldValue"), OldValue); - Result->SetStringField(TEXT("newValue"), NewValue); - Result->SetBoolField(TEXT("dryRun"), bDryRun); - if (!bDryRun) - { - Result->SetBoolField(TEXT("saved"), bSaved); - } -} - -// ============================================================ -// HandleAddMaterialExpression — add a new expression to a material -// ============================================================ - -void FBlueprintMCPServer::HandleAddMaterialExpression(const FJsonObject* Json, FJsonObject* Result) -{ - FString MaterialName = Json->GetStringField(TEXT("material")); - FString ExpressionClassName = Json->GetStringField(TEXT("expressionClass")); - - if (MaterialName.IsEmpty() && !Json->HasField(TEXT("materialFunction"))) - { - return MCPUtils::MakeErrorJson(Result, TEXT("Missing required field: 'material' or 'materialFunction'")); - } - if (ExpressionClassName.IsEmpty()) - { - return MCPUtils::MakeErrorJson(Result, TEXT("Missing required field: expressionClass")); - } - - int32 PosX = 0, PosY = 0; - if (Json->HasField(TEXT("posX"))) - PosX = (int32)Json->GetNumberField(TEXT("posX")); - if (Json->HasField(TEXT("posY"))) - PosY = (int32)Json->GetNumberField(TEXT("posY")); - - bool bDryRun = false; - Json->TryGetBoolField(TEXT("dryRun"), bDryRun); - - // Map string class name to UClass via dynamic lookup - UClass* ExprClass = nullptr; - - // Convenience aliases for backward compatibility - static TMap Aliases = { - {TEXT("Lerp"), TEXT("LinearInterpolate")}, - }; - - FString LookupName = ExpressionClassName; - if (const FString* Alias = Aliases.Find(ExpressionClassName)) - { - LookupName = *Alias; - } - - // Dynamic lookup: find UMaterialExpression via UClass iteration - FString FullClassName = FString::Printf(TEXT("MaterialExpression%s"), *LookupName); - for (TObjectIterator It; It; ++It) - { - if (It->GetName() == FullClassName && It->IsChildOf(UMaterialExpression::StaticClass())) - { - ExprClass = *It; - break; - } - } - - if (!ExprClass) - { - return MCPUtils::MakeErrorJson(Result, FString::Printf( - TEXT("Unknown expression class '%s'. Use the UMaterialExpression subclass name without the 'MaterialExpression' prefix " - "(e.g. 'Constant', 'ScalarParameter', 'Add', 'Multiply', 'Lerp', 'Subtract', 'Fresnel', 'Comment', etc.)"), - *ExpressionClassName)); - } - if (ExprClass->HasAnyClassFlags(CLASS_Abstract)) - { - return MCPUtils::MakeErrorJson(Result, FString::Printf( - TEXT("Expression class '%s' is abstract and cannot be instantiated."), *ExpressionClassName)); - } - - // Load material or material function - FString MaterialFunctionName = Json->GetStringField(TEXT("materialFunction")); - UMaterial* Material = nullptr; - UMaterialFunction* MatFunc = nullptr; - UObject* Owner = nullptr; - FString AssetDisplayName; - - if (!MaterialFunctionName.IsEmpty()) - { - if (!MaterialName.IsEmpty()) - { - return MCPUtils::MakeErrorJson(Result, TEXT("Specify either 'material' or 'materialFunction', not both")); - } - MatFunc = UMCPAssetFinder::LoadAsset(MaterialFunctionName, Result); - if (!MatFunc) return; - Owner = MatFunc; - AssetDisplayName = MatFunc->GetName(); - } - else - { - Material = UMCPAssetFinder::LoadAsset(MaterialName, Result); - if (!Material) return; - Owner = Material; - AssetDisplayName = Material->GetName(); - } - - if (bDryRun) - { - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: [DRY RUN] Would add expression '%s' to '%s' at (%d, %d)"), - *ExpressionClassName, *AssetDisplayName, PosX, PosY); - - Result->SetBoolField(TEXT("success"), true); - Result->SetBoolField(TEXT("dryRun"), true); - Result->SetStringField(TEXT("material"), AssetDisplayName); - Result->SetStringField(TEXT("expressionClass"), ExpressionClassName); - Result->SetNumberField(TEXT("posX"), PosX); - Result->SetNumberField(TEXT("posY"), PosY); - return; - } - - // Ensure the MaterialGraph exists (commandlet mode doesn't auto-create it) - if (Material) MCPUtils::EnsureMaterialGraph(Material); - - // Create, register, and PostEditChange the expression — all inside an SEH wrapper because - // some classes (e.g. UMaterialExpressionParameter) lack CLASS_Abstract but crash during - // PostEditChange. The SEH wrapper cleans up the bad expression on crash. - UMaterialExpression* NewExpr = nullptr; -#if PLATFORM_WINDOWS - int32 CreateResult = TryAddMaterialExpressionSEH(Owner, ExprClass, Material, MatFunc, PosX, PosY, &NewExpr); - if (CreateResult != 0 || !NewExpr) - { - return MCPUtils::MakeErrorJson(Result, FString::Printf( - TEXT("Expression class '%s' cannot be instantiated (may be abstract or have internal errors)."), - *ExpressionClassName)); - } -#else - NewExpr = NewObject(Owner, ExprClass); - if (!NewExpr) - { - return MCPUtils::MakeErrorJson(Result, TEXT("Failed to create material expression object")); - } - NewExpr->MaterialExpressionEditorX = PosX; - NewExpr->MaterialExpressionEditorY = PosY; - if (Material) - { - Material->GetExpressionCollection().AddExpression(NewExpr); - if (Material->MaterialGraph) - { - Material->MaterialGraph->RebuildGraph(); - } - Material->PreEditChange(nullptr); - Material->PostEditChange(); - Material->MarkPackageDirty(); - } - else if (MatFunc) - { - MatFunc->GetExpressionCollection().AddExpression(NewExpr); - MatFunc->PreEditChange(nullptr); - MatFunc->PostEditChange(); - MatFunc->MarkPackageDirty(); - } -#endif - - // Save - bool bSaved = Material ? MCPUtils::SaveMaterialPackage(Material) : MCPUtils::SaveGenericPackage(MatFunc); - - // Find the node GUID from the material graph (only for materials) - FString NodeGuid; - if (Material && Material->MaterialGraph) - { - for (UEdGraphNode* Node : Material->MaterialGraph->Nodes) - { - UMaterialGraphNode* MatNode = Cast(Node); - if (MatNode && MatNode->MaterialExpression == NewExpr) - { - NodeGuid = Node->NodeGuid.ToString(); - break; - } - } - } - - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Added expression '%s' to '%s' (nodeId: %s, saved: %s)"), - *ExpressionClassName, *AssetDisplayName, *NodeGuid, bSaved ? TEXT("true") : TEXT("false")); - - // Serialize the expression details - TSharedPtr ExprDetails = MCPUtils::SerializeMaterialExpression(NewExpr); - - Result->SetBoolField(TEXT("success"), true); - Result->SetStringField(TEXT("material"), AssetDisplayName); - Result->SetStringField(TEXT("expressionClass"), ExpressionClassName); - Result->SetStringField(TEXT("nodeId"), NodeGuid); - Result->SetNumberField(TEXT("posX"), PosX); - Result->SetNumberField(TEXT("posY"), PosY); - if (ExprDetails.IsValid()) - { - Result->SetObjectField(TEXT("expression"), ExprDetails); - } - Result->SetBoolField(TEXT("saved"), bSaved); -} - -// ============================================================ -// HandleDeleteMaterialExpression — remove an expression from a material -// ============================================================ - -void FBlueprintMCPServer::HandleDeleteMaterialExpression(const FJsonObject* Json, FJsonObject* Result) -{ - FString MaterialName = Json->GetStringField(TEXT("material")); - FString MaterialFunctionName = Json->GetStringField(TEXT("materialFunction")); - FString NodeId = Json->GetStringField(TEXT("nodeId")); - - if (MaterialName.IsEmpty() && MaterialFunctionName.IsEmpty()) - { - return MCPUtils::MakeErrorJson(Result, TEXT("Missing required field: 'material' or 'materialFunction'")); - } - if (NodeId.IsEmpty()) - { - return MCPUtils::MakeErrorJson(Result, TEXT("Missing required field: nodeId")); - } - - bool bDryRun = false; - Json->TryGetBoolField(TEXT("dryRun"), bDryRun); - - // Load material or material function - UMaterial* Material = nullptr; - UMaterialFunction* MatFunc = nullptr; - FString AssetDisplayName; - - if (!MaterialFunctionName.IsEmpty()) - { - MatFunc = UMCPAssetFinder::LoadAsset(MaterialFunctionName, Result); - if (!MatFunc) return; - AssetDisplayName = MatFunc->GetName(); - } - else - { - Material = UMCPAssetFinder::LoadAsset(MaterialName, Result); - if (!Material) return; - AssetDisplayName = Material->GetName(); - } - - // For materials, we need the graph to find nodes by GUID - if (Material) MCPUtils::EnsureMaterialGraph(Material); - UEdGraph* Graph = Material ? (UEdGraph*)Material->MaterialGraph : (MatFunc ? MatFunc->MaterialGraph : nullptr); - if (!Graph) - { - return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("'%s' has no material graph"), *AssetDisplayName)); - } - - // Find the node by GUID - UMaterialGraphNode* TargetMatNode = nullptr; - for (UEdGraphNode* Node : Graph->Nodes) - { - if (!Node) continue; - if (Node->NodeGuid.ToString() == NodeId) - { - TargetMatNode = Cast(Node); - break; - } - } - - if (!TargetMatNode) - { - return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Node '%s' not found in material graph"), *NodeId)); - } - - if (!TargetMatNode->MaterialExpression) - { - return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Node '%s' has no associated material expression"), *NodeId)); - } - - // Capture info before deletion - FString DeletedNodeTitle = TargetMatNode->GetNodeTitle(ENodeTitleType::FullTitle).ToString(); - FString DeletedExprClass = TargetMatNode->MaterialExpression->GetClass()->GetName(); - - if (bDryRun) - { - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: [DRY RUN] Would delete expression '%s' (nodeId: %s) from '%s'"), - *DeletedExprClass, *NodeId, *AssetDisplayName); - - Result->SetBoolField(TEXT("success"), true); - Result->SetBoolField(TEXT("dryRun"), true); - Result->SetStringField(TEXT("material"), AssetDisplayName); - Result->SetStringField(TEXT("deletedNode"), NodeId); - Result->SetStringField(TEXT("deletedNodeTitle"), DeletedNodeTitle); - Result->SetStringField(TEXT("deletedExpressionClass"), DeletedExprClass); - return; - } - - // Remove the expression - UMaterialExpression* ExprToRemove = TargetMatNode->MaterialExpression; - if (Material) - { - Material->GetExpressionCollection().RemoveExpression(ExprToRemove); - } - else - { - MatFunc->GetExpressionCollection().RemoveExpression(ExprToRemove); - } - ExprToRemove->MarkAsGarbage(); - - // Rebuild graph - Graph->NotifyGraphChanged(); - - UObject* Asset = Material ? (UObject*)Material : (UObject*)MatFunc; - Asset->PreEditChange(nullptr); - Asset->PostEditChange(); - Asset->MarkPackageDirty(); - - // Save - bool bSaved = Material ? MCPUtils::SaveMaterialPackage(Material) : MCPUtils::SaveGenericPackage(MatFunc); - - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Deleted expression '%s' (nodeId: %s) from '%s' (saved: %s)"), - *DeletedExprClass, *NodeId, *AssetDisplayName, bSaved ? TEXT("true") : TEXT("false")); - - Result->SetBoolField(TEXT("success"), true); - Result->SetStringField(TEXT("material"), AssetDisplayName); - Result->SetStringField(TEXT("deletedNode"), NodeId); - Result->SetStringField(TEXT("deletedNodeTitle"), DeletedNodeTitle); - Result->SetStringField(TEXT("deletedExpressionClass"), DeletedExprClass); - Result->SetBoolField(TEXT("saved"), bSaved); -} - -// ============================================================ -// HandleConnectMaterialPins — connect two pins in a material graph -// ============================================================ - -void FBlueprintMCPServer::HandleConnectMaterialPins(const FJsonObject* Json, FJsonObject* Result) -{ - FString MaterialName = Json->GetStringField(TEXT("material")); - FString MaterialFunctionName = Json->GetStringField(TEXT("materialFunction")); - 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 (MaterialName.IsEmpty() && MaterialFunctionName.IsEmpty()) - { - return MCPUtils::MakeErrorJson(Result, TEXT("Missing required field: 'material' or 'materialFunction'")); - } - if (SourceNodeId.IsEmpty() || SourcePinName.IsEmpty() || TargetNodeId.IsEmpty() || TargetPinName.IsEmpty()) - { - return MCPUtils::MakeErrorJson(Result, TEXT("Missing required fields: sourceNodeId, sourcePinName, targetNodeId, targetPinName")); - } - - bool bDryRun = false; - Json->TryGetBoolField(TEXT("dryRun"), bDryRun); - - // Load material or material function - UMaterial* Material = nullptr; - UMaterialFunction* MatFunc = nullptr; - FString AssetDisplayName; - - if (!MaterialFunctionName.IsEmpty()) - { - MatFunc = UMCPAssetFinder::LoadAsset(MaterialFunctionName, Result); - if (!MatFunc) return; - AssetDisplayName = MatFunc->GetName(); - } - else - { - Material = UMCPAssetFinder::LoadAsset(MaterialName, Result); - if (!Material) return; - AssetDisplayName = Material->GetName(); - } - - if (Material) MCPUtils::EnsureMaterialGraph(Material); - UEdGraph* Graph = Material ? (UEdGraph*)Material->MaterialGraph : (MatFunc ? MatFunc->MaterialGraph : nullptr); - if (!Graph) - { - return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("'%s' has no material graph"), *AssetDisplayName)); - } - - // Find source and target nodes by GUID - UEdGraphNode* SourceNode = nullptr; - UEdGraphNode* TargetNode = nullptr; - - for (UEdGraphNode* Node : Graph->Nodes) - { - if (!Node) continue; - if (Node->NodeGuid.ToString() == SourceNodeId) - SourceNode = Node; - if (Node->NodeGuid.ToString() == TargetNodeId) - TargetNode = Node; - if (SourceNode && TargetNode) - break; - } - - if (!SourceNode) - { - return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Source node '%s' not found in material graph"), *SourceNodeId)); - } - if (!TargetNode) - { - return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Target node '%s' not found in material graph"), *TargetNodeId)); - } - - // Find pins - UEdGraphPin* SourcePin = SourceNode->FindPin(FName(*SourcePinName)); - if (!SourcePin) - { - // List available pins for debugging - TArray> PinNames; - for (UEdGraphPin* P : SourceNode->Pins) - { - if (P) PinNames.Add(MakeShared( - FString::Printf(TEXT("%s (%s)"), *P->PinName.ToString(), - P->Direction == EGPD_Input ? TEXT("Input") : TEXT("Output")))); - } - MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Source pin '%s' not found on node '%s'"), - *SourcePinName, *SourceNodeId)); - Result->SetArrayField(TEXT("availablePins"), PinNames); - return; - } - - UEdGraphPin* TargetPin = TargetNode->FindPin(FName(*TargetPinName)); - if (!TargetPin) - { - TArray> PinNames; - for (UEdGraphPin* P : TargetNode->Pins) - { - if (P) PinNames.Add(MakeShared( - FString::Printf(TEXT("%s (%s)"), *P->PinName.ToString(), - P->Direction == EGPD_Input ? TEXT("Input") : TEXT("Output")))); - } - MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Target pin '%s' not found on node '%s'"), - *TargetPinName, *TargetNodeId)); - Result->SetArrayField(TEXT("availablePins"), PinNames); - return; - } - - if (bDryRun) - { - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: [DRY RUN] Would connect %s.%s -> %s.%s in '%s'"), - *SourceNodeId, *SourcePinName, *TargetNodeId, *TargetPinName, *AssetDisplayName); - - Result->SetBoolField(TEXT("success"), true); - Result->SetBoolField(TEXT("dryRun"), true); - Result->SetBoolField(TEXT("connected"), false); - Result->SetStringField(TEXT("material"), AssetDisplayName); - return; - } - - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Connecting %s.%s -> %s.%s in '%s'"), - *SourceNodeId, *SourcePinName, *TargetNodeId, *TargetPinName, *AssetDisplayName); - - // Try to connect via the schema - const UEdGraphSchema* Schema = Graph->GetSchema(); - if (!Schema) - { - return MCPUtils::MakeErrorJson(Result, TEXT("Material graph schema not found")); - } - - bool bConnected = Schema->TryCreateConnection(SourcePin, TargetPin); - - Result->SetBoolField(TEXT("success"), bConnected); - Result->SetBoolField(TEXT("connected"), bConnected); - Result->SetStringField(TEXT("material"), AssetDisplayName); - - if (!bConnected) - { - return MCPUtils::MakeErrorJson(Result, FString::Printf( - TEXT("Cannot connect %s.%s to %s.%s — types may be incompatible"), - *SourceNodeId, *SourcePinName, *TargetNodeId, *TargetPinName)); - } - - // Save - UObject* Asset = Material ? (UObject*)Material : (UObject*)MatFunc; - Asset->PreEditChange(nullptr); - Asset->PostEditChange(); - bool bSaved = Material ? MCPUtils::SaveMaterialPackage(Material) : MCPUtils::SaveGenericPackage(MatFunc); - Result->SetBoolField(TEXT("saved"), bSaved); -} - -// ============================================================ -// HandleDisconnectMaterialPin — break connections on a pin in a material graph -// ============================================================ - -void FBlueprintMCPServer::HandleDisconnectMaterialPin(const FJsonObject* Json, FJsonObject* Result) -{ - FString MaterialName = Json->GetStringField(TEXT("material")); - FString MaterialFunctionName = Json->GetStringField(TEXT("materialFunction")); - FString NodeId = Json->GetStringField(TEXT("nodeId")); - FString PinName = Json->GetStringField(TEXT("pinName")); - - if (MaterialName.IsEmpty() && MaterialFunctionName.IsEmpty()) - { - return MCPUtils::MakeErrorJson(Result, TEXT("Missing required field: 'material' or 'materialFunction'")); - } - if (NodeId.IsEmpty() || PinName.IsEmpty()) - { - return MCPUtils::MakeErrorJson(Result, TEXT("Missing required fields: nodeId, pinName")); - } - - bool bDryRun = false; - Json->TryGetBoolField(TEXT("dryRun"), bDryRun); - - // Load material or material function - UMaterial* Material = nullptr; - UMaterialFunction* MatFunc = nullptr; - FString AssetDisplayName; - - if (!MaterialFunctionName.IsEmpty()) - { - MatFunc = UMCPAssetFinder::LoadAsset(MaterialFunctionName, Result); - if (!MatFunc) return; - AssetDisplayName = MatFunc->GetName(); - } - else - { - Material = UMCPAssetFinder::LoadAsset(MaterialName, Result); - if (!Material) return; - AssetDisplayName = Material->GetName(); - } - - if (Material) MCPUtils::EnsureMaterialGraph(Material); - UEdGraph* Graph = Material ? (UEdGraph*)Material->MaterialGraph : (MatFunc ? MatFunc->MaterialGraph : nullptr); - if (!Graph) - { - return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("'%s' has no material graph"), *AssetDisplayName)); - } - - // Find node by GUID - UEdGraphNode* TargetNode = nullptr; - for (UEdGraphNode* Node : Graph->Nodes) - { - if (!Node) continue; - if (Node->NodeGuid.ToString() == NodeId) - { - TargetNode = Node; - break; - } - } - - if (!TargetNode) - { - return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Node '%s' not found in material graph"), *NodeId)); - } - - // Find pin - UEdGraphPin* Pin = TargetNode->FindPin(FName(*PinName)); - if (!Pin) - { - TArray> PinNames; - for (UEdGraphPin* P : TargetNode->Pins) - { - if (P) PinNames.Add(MakeShared( - FString::Printf(TEXT("%s (%s)"), *P->PinName.ToString(), - P->Direction == EGPD_Input ? TEXT("Input") : TEXT("Output")))); - } - MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Pin '%s' not found on node '%s'"), - *PinName, *NodeId)); - Result->SetArrayField(TEXT("availablePins"), PinNames); - return; - } - - int32 BrokenCount = Pin->LinkedTo.Num(); - - if (bDryRun) - { - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: [DRY RUN] Would disconnect pin '%s' on node '%s' in '%s' (%d links)"), - *PinName, *NodeId, *AssetDisplayName, BrokenCount); - - Result->SetBoolField(TEXT("success"), true); - Result->SetBoolField(TEXT("dryRun"), true); - Result->SetStringField(TEXT("material"), AssetDisplayName); - Result->SetStringField(TEXT("nodeId"), NodeId); - Result->SetStringField(TEXT("pinName"), PinName); - Result->SetNumberField(TEXT("brokenLinkCount"), BrokenCount); - return; - } - - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Disconnecting pin '%s' on node '%s' in '%s' (%d links)"), - *PinName, *NodeId, *AssetDisplayName, BrokenCount); - - // Break all links - Pin->BreakAllPinLinks(); - - UObject* Asset = Material ? (UObject*)Material : (UObject*)MatFunc; - Asset->PreEditChange(nullptr); - Asset->PostEditChange(); - - // Save - bool bSaved = Material ? MCPUtils::SaveMaterialPackage(Material) : MCPUtils::SaveGenericPackage(MatFunc); - - Result->SetBoolField(TEXT("success"), true); - Result->SetStringField(TEXT("material"), AssetDisplayName); - Result->SetStringField(TEXT("nodeId"), NodeId); - Result->SetStringField(TEXT("pinName"), PinName); - Result->SetNumberField(TEXT("brokenLinkCount"), BrokenCount); - Result->SetBoolField(TEXT("saved"), bSaved); -} - -// ============================================================ -// HandleSetExpressionValue — set value on a material expression -// ============================================================ - -void FBlueprintMCPServer::HandleSetExpressionValue(const FJsonObject* Json, FJsonObject* Result) -{ - FString MaterialName = Json->GetStringField(TEXT("material")); - FString MaterialFunctionName = Json->GetStringField(TEXT("materialFunction")); - FString NodeId = Json->GetStringField(TEXT("nodeId")); - - if (MaterialName.IsEmpty() && MaterialFunctionName.IsEmpty()) - { - return MCPUtils::MakeErrorJson(Result, TEXT("Missing required field: 'material' or 'materialFunction'")); - } - if (NodeId.IsEmpty()) - { - return MCPUtils::MakeErrorJson(Result, TEXT("Missing required field: nodeId")); - } - - if (!Json->HasField(TEXT("value"))) - { - return MCPUtils::MakeErrorJson(Result, TEXT("Missing required field: value")); - } - - // Load material or material function - UMaterial* Material = nullptr; - UMaterialFunction* MatFunc = nullptr; - FString AssetDisplayName; - - if (!MaterialFunctionName.IsEmpty()) - { - MatFunc = UMCPAssetFinder::LoadAsset(MaterialFunctionName, Result); - if (!MatFunc) return; - AssetDisplayName = MatFunc->GetName(); - } - else - { - Material = UMCPAssetFinder::LoadAsset(MaterialName, Result); - if (!Material) return; - AssetDisplayName = Material->GetName(); - } - - if (Material) MCPUtils::EnsureMaterialGraph(Material); - UEdGraph* Graph = Material ? (UEdGraph*)Material->MaterialGraph : (MatFunc ? MatFunc->MaterialGraph : nullptr); - if (!Graph) - { - return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("'%s' has no material graph"), *AssetDisplayName)); - } - - // Find the node by GUID - UMaterialGraphNode* TargetMatNode = nullptr; - for (UEdGraphNode* Node : Graph->Nodes) - { - if (!Node) continue; - if (Node->NodeGuid.ToString() == NodeId) - { - TargetMatNode = Cast(Node); - break; - } - } - - if (!TargetMatNode) - { - return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Node '%s' not found in material graph"), *NodeId)); - } - - UMaterialExpression* Expr = TargetMatNode->MaterialExpression; - if (!Expr) - { - return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Node '%s' has no associated material expression"), *NodeId)); - } - - FString ExprType; - FString NewValueStr; - - UObject* Asset = Material ? (UObject*)Material : (UObject*)MatFunc; - Asset->PreEditChange(nullptr); - - // Handle based on expression type - if (UMaterialExpressionConstant* ConstExpr = Cast(Expr)) - { - ExprType = TEXT("Constant"); - double Value = Json->GetNumberField(TEXT("value")); - ConstExpr->R = (float)Value; - NewValueStr = FString::Printf(TEXT("%f"), Value); - } - else if (UMaterialExpressionConstant3Vector* C3Expr = Cast(Expr)) - { - ExprType = TEXT("Constant3Vector"); - const TSharedPtr* ValueObj = nullptr; - if (Json->TryGetObjectField(TEXT("value"), ValueObj) && ValueObj && (*ValueObj).IsValid()) - { - double R = 0, G = 0, B = 0; - (*ValueObj)->TryGetNumberField(TEXT("r"), R); - (*ValueObj)->TryGetNumberField(TEXT("g"), G); - (*ValueObj)->TryGetNumberField(TEXT("b"), B); - C3Expr->Constant = FLinearColor((float)R, (float)G, (float)B); - NewValueStr = FString::Printf(TEXT("(%f, %f, %f)"), R, G, B); - } - else - { - Asset->PostEditChange(); - return MCPUtils::MakeErrorJson(Result, TEXT("Constant3Vector requires value as object {r, g, b}")); - } - } - else if (UMaterialExpressionConstant4Vector* C4Expr = Cast(Expr)) - { - ExprType = TEXT("Constant4Vector"); - const TSharedPtr* ValueObj = nullptr; - if (Json->TryGetObjectField(TEXT("value"), ValueObj) && ValueObj && (*ValueObj).IsValid()) - { - double R = 0, G = 0, B = 0, A = 1; - (*ValueObj)->TryGetNumberField(TEXT("r"), R); - (*ValueObj)->TryGetNumberField(TEXT("g"), G); - (*ValueObj)->TryGetNumberField(TEXT("b"), B); - (*ValueObj)->TryGetNumberField(TEXT("a"), A); - C4Expr->Constant = FLinearColor((float)R, (float)G, (float)B, (float)A); - NewValueStr = FString::Printf(TEXT("(%f, %f, %f, %f)"), R, G, B, A); - } - else - { - Asset->PostEditChange(); - return MCPUtils::MakeErrorJson(Result, TEXT("Constant4Vector requires value as object {r, g, b, a}")); - } - } - else if (UMaterialExpressionScalarParameter* SPExpr = Cast(Expr)) - { - ExprType = TEXT("ScalarParameter"); - double Value = Json->GetNumberField(TEXT("value")); - SPExpr->DefaultValue = (float)Value; - NewValueStr = FString::Printf(TEXT("%f"), Value); - - FString ParamName; - if (Json->TryGetStringField(TEXT("parameterName"), ParamName) && !ParamName.IsEmpty()) - { - SPExpr->ParameterName = FName(*ParamName); - } - } - else if (UMaterialExpressionVectorParameter* VPExpr = Cast(Expr)) - { - ExprType = TEXT("VectorParameter"); - const TSharedPtr* ValueObj = nullptr; - if (Json->TryGetObjectField(TEXT("value"), ValueObj) && ValueObj && (*ValueObj).IsValid()) - { - double R = 0, G = 0, B = 0, A = 1; - (*ValueObj)->TryGetNumberField(TEXT("r"), R); - (*ValueObj)->TryGetNumberField(TEXT("g"), G); - (*ValueObj)->TryGetNumberField(TEXT("b"), B); - (*ValueObj)->TryGetNumberField(TEXT("a"), A); - VPExpr->DefaultValue = FLinearColor((float)R, (float)G, (float)B, (float)A); - NewValueStr = FString::Printf(TEXT("(%f, %f, %f, %f)"), R, G, B, A); - } - else - { - Asset->PostEditChange(); - return MCPUtils::MakeErrorJson(Result, TEXT("VectorParameter requires value as object {r, g, b, a}")); - } - - FString ParamName; - if (Json->TryGetStringField(TEXT("parameterName"), ParamName) && !ParamName.IsEmpty()) - { - VPExpr->ParameterName = FName(*ParamName); - } - } - else if (UMaterialExpressionTextureCoordinate* TCExpr = Cast(Expr)) - { - ExprType = TEXT("TextureCoordinate"); - const TSharedPtr* ValueObj = nullptr; - if (Json->TryGetObjectField(TEXT("value"), ValueObj) && ValueObj && (*ValueObj).IsValid()) - { - double CoordIndex = 0, UTiling = 1, VTiling = 1; - (*ValueObj)->TryGetNumberField(TEXT("coordinateIndex"), CoordIndex); - (*ValueObj)->TryGetNumberField(TEXT("uTiling"), UTiling); - (*ValueObj)->TryGetNumberField(TEXT("vTiling"), VTiling); - TCExpr->CoordinateIndex = (int32)CoordIndex; - TCExpr->UTiling = (float)UTiling; - TCExpr->VTiling = (float)VTiling; - NewValueStr = FString::Printf(TEXT("(index=%d, uTiling=%f, vTiling=%f)"), (int32)CoordIndex, UTiling, VTiling); - } - else - { - Asset->PostEditChange(); - return MCPUtils::MakeErrorJson(Result, TEXT("TextureCoordinate requires value as object {coordinateIndex, uTiling, vTiling}")); - } - } - else if (UMaterialExpressionCustom* CustomExpr = Cast(Expr)) - { - ExprType = TEXT("Custom"); - FString Code; - if (Json->TryGetStringField(TEXT("code"), Code)) - { - CustomExpr->Code = Code; - NewValueStr = FString::Printf(TEXT("Code: %d chars"), Code.Len()); - } - else if (Json->HasField(TEXT("value"))) - { - // Also accept code via value field as string - FString ValueStr = Json->GetStringField(TEXT("value")); - if (!ValueStr.IsEmpty()) - { - CustomExpr->Code = ValueStr; - NewValueStr = FString::Printf(TEXT("Code: %d chars"), ValueStr.Len()); - } - } - - FString OutputTypeStr; - if (Json->TryGetStringField(TEXT("outputType"), OutputTypeStr) && !OutputTypeStr.IsEmpty()) - { - int64 EnumVal = StaticEnum()->GetValueByNameString(OutputTypeStr); - if (EnumVal != INDEX_NONE) - { - CustomExpr->OutputType = (ECustomMaterialOutputType)EnumVal; - } - } - } - else if (UMaterialExpressionComponentMask* CMExpr = Cast(Expr)) - { - ExprType = TEXT("ComponentMask"); - const TSharedPtr* ValueObj = nullptr; - if (Json->TryGetObjectField(TEXT("value"), ValueObj) && ValueObj && (*ValueObj).IsValid()) - { - bool bR = false, bG = false, bB = false, bA = false; - (*ValueObj)->TryGetBoolField(TEXT("r"), bR); - (*ValueObj)->TryGetBoolField(TEXT("g"), bG); - (*ValueObj)->TryGetBoolField(TEXT("b"), bB); - (*ValueObj)->TryGetBoolField(TEXT("a"), bA); - CMExpr->R = bR ? 1 : 0; - CMExpr->G = bG ? 1 : 0; - CMExpr->B = bB ? 1 : 0; - CMExpr->A = bA ? 1 : 0; - NewValueStr = FString::Printf(TEXT("(R=%s, G=%s, B=%s, A=%s)"), - bR ? TEXT("true") : TEXT("false"), - bG ? TEXT("true") : TEXT("false"), - bB ? TEXT("true") : TEXT("false"), - bA ? TEXT("true") : TEXT("false")); - } - else - { - Asset->PostEditChange(); - return MCPUtils::MakeErrorJson(Result, TEXT("ComponentMask requires value as object {r, g, b, a} (booleans)")); - } - } - else - { - Asset->PostEditChange(); - return MCPUtils::MakeErrorJson(Result, FString::Printf( - TEXT("Expression type '%s' does not support direct value setting. Supported types: Constant, " - "Constant3Vector, Constant4Vector, ScalarParameter, VectorParameter, TextureCoordinate, " - "Custom, ComponentMask"), - *Expr->GetClass()->GetName())); - } - - Asset->PostEditChange(); - Asset->MarkPackageDirty(); - - // Save - bool bSaved = Material ? MCPUtils::SaveMaterialPackage(Material) : MCPUtils::SaveGenericPackage(MatFunc); - - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Set expression value on node '%s' (%s) in '%s': %s"), - *NodeId, *ExprType, *AssetDisplayName, *NewValueStr); - - Result->SetBoolField(TEXT("success"), true); - Result->SetStringField(TEXT("material"), AssetDisplayName); - Result->SetStringField(TEXT("nodeId"), NodeId); - Result->SetStringField(TEXT("expressionType"), ExprType); - Result->SetStringField(TEXT("newValue"), NewValueStr); - Result->SetBoolField(TEXT("saved"), bSaved); -} - -// ============================================================ -// HandleMoveMaterialExpression — reposition a material graph node -// ============================================================ - -void FBlueprintMCPServer::HandleMoveMaterialExpression(const FJsonObject* Json, FJsonObject* Result) -{ - FString MaterialName = Json->GetStringField(TEXT("material")); - FString MaterialFunctionName = Json->GetStringField(TEXT("materialFunction")); - FString NodeId = Json->GetStringField(TEXT("nodeId")); - - if (MaterialName.IsEmpty() && MaterialFunctionName.IsEmpty()) - { - return MCPUtils::MakeErrorJson(Result, TEXT("Missing required field: 'material' or 'materialFunction'")); - } - if (NodeId.IsEmpty()) - { - return MCPUtils::MakeErrorJson(Result, TEXT("Missing required field: nodeId")); - } - - if (!Json->HasField(TEXT("posX")) || !Json->HasField(TEXT("posY"))) - { - return MCPUtils::MakeErrorJson(Result, TEXT("Missing required fields: posX, posY")); - } - - int32 PosX = (int32)Json->GetNumberField(TEXT("posX")); - int32 PosY = (int32)Json->GetNumberField(TEXT("posY")); - - bool bDryRun = false; - Json->TryGetBoolField(TEXT("dryRun"), bDryRun); - - // Load material or material function - UMaterial* Material = nullptr; - UMaterialFunction* MatFunc = nullptr; - FString AssetDisplayName; - - if (!MaterialFunctionName.IsEmpty()) - { - MatFunc = UMCPAssetFinder::LoadAsset(MaterialFunctionName, Result); - if (!MatFunc) return; - AssetDisplayName = MatFunc->GetName(); - } - else - { - Material = UMCPAssetFinder::LoadAsset(MaterialName, Result); - if (!Material) return; - AssetDisplayName = Material->GetName(); - } - - if (Material) MCPUtils::EnsureMaterialGraph(Material); - UEdGraph* Graph = Material ? (UEdGraph*)Material->MaterialGraph : (MatFunc ? MatFunc->MaterialGraph : nullptr); - if (!Graph) - { - return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("'%s' has no material graph"), *AssetDisplayName)); - } - - // Find node by GUID - UMaterialGraphNode* TargetMatNode = nullptr; - for (UEdGraphNode* Node : Graph->Nodes) - { - if (!Node) continue; - if (Node->NodeGuid.ToString() == NodeId) - { - TargetMatNode = Cast(Node); - break; - } - } - - if (!TargetMatNode) - { - return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Node '%s' not found in material graph"), *NodeId)); - } - - if (bDryRun) - { - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: [DRY RUN] Would move node '%s' to (%d, %d) in '%s'"), - *NodeId, PosX, PosY, *AssetDisplayName); - - Result->SetBoolField(TEXT("success"), true); - Result->SetBoolField(TEXT("dryRun"), true); - Result->SetStringField(TEXT("material"), AssetDisplayName); - Result->SetStringField(TEXT("nodeId"), NodeId); - Result->SetNumberField(TEXT("posX"), PosX); - Result->SetNumberField(TEXT("posY"), PosY); - return; - } - - // Set position on the graph node - TargetMatNode->NodePosX = PosX; - TargetMatNode->NodePosY = PosY; - - // Also update the underlying expression position - if (TargetMatNode->MaterialExpression) - { - TargetMatNode->MaterialExpression->MaterialExpressionEditorX = PosX; - TargetMatNode->MaterialExpression->MaterialExpressionEditorY = PosY; - } - - UObject* Asset = Material ? (UObject*)Material : (UObject*)MatFunc; - Asset->PreEditChange(nullptr); - Asset->PostEditChange(); - - // Save - bool bSaved = Material ? MCPUtils::SaveMaterialPackage(Material) : MCPUtils::SaveGenericPackage(MatFunc); - - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Moved node '%s' to (%d, %d) in '%s' (saved: %s)"), - *NodeId, PosX, PosY, *AssetDisplayName, bSaved ? TEXT("true") : TEXT("false")); - - Result->SetBoolField(TEXT("success"), true); - Result->SetStringField(TEXT("material"), AssetDisplayName); - Result->SetStringField(TEXT("nodeId"), NodeId); - Result->SetNumberField(TEXT("posX"), PosX); - Result->SetNumberField(TEXT("posY"), PosY); - Result->SetBoolField(TEXT("saved"), bSaved); -} - -// ============================================================ -// Phase 4: Create Material Function -// ============================================================ - -// ============================================================ -// HandleCreateMaterialFunction — create a new UMaterialFunction asset -// ============================================================ - -void FBlueprintMCPServer::HandleCreateMaterialFunction(const FJsonObject* Json, FJsonObject* Result) -{ - FString Name = Json->GetStringField(TEXT("name")); - FString PackagePath = Json->GetStringField(TEXT("packagePath")); - - if (Name.IsEmpty() || PackagePath.IsEmpty()) - { - return MCPUtils::MakeErrorJson(Result, TEXT("Missing required fields: name, packagePath")); - } - - if (!PackagePath.StartsWith(TEXT("/Game"))) - { - return MCPUtils::MakeErrorJson(Result, TEXT("packagePath must start with '/Game'")); - } - - // Check if asset already exists - FString FullAssetPath = PackagePath / Name; - if (UMCPAssetFinder::FindAsset(UMaterialFunction::StaticClass(), Name) || UMCPAssetFinder::FindAsset(UMaterialFunction::StaticClass(), FullAssetPath)) - { - return MCPUtils::MakeErrorJson(Result, FString::Printf( - TEXT("Material Function '%s' already exists. Use a different name or delete the existing asset first."), - *Name)); - } - - FString Description; - Json->TryGetStringField(TEXT("description"), Description); - - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Creating Material Function '%s' in '%s'"), *Name, *PackagePath); - - // Create via IAssetTools + factory - IAssetTools& AssetTools = FModuleManager::LoadModuleChecked("AssetTools").Get(); - UMaterialFunctionFactoryNew* Factory = NewObject(); - UObject* NewAsset = AssetTools.CreateAsset(Name, PackagePath, UMaterialFunction::StaticClass(), Factory); - - if (!NewAsset) - { - return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Failed to create Material Function '%s' in '%s'"), *Name, *PackagePath)); - } - - UMaterialFunction* MF = Cast(NewAsset); - if (!MF) - { - return MCPUtils::MakeErrorJson(Result, TEXT("Created asset is not a UMaterialFunction")); - } - - // Set optional description - if (!Description.IsEmpty()) - { - MF->Description = Description; - } - - // Save - bool bSaved = MCPUtils::SaveGenericPackage(MF); - - - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Created Material Function '%s' (saved: %s)"), - *Name, bSaved ? TEXT("true") : TEXT("false")); - - Result->SetBoolField(TEXT("success"), true); - Result->SetStringField(TEXT("name"), Name); - Result->SetStringField(TEXT("path"), MF->GetPathName()); - if (!Description.IsEmpty()) - { - Result->SetStringField(TEXT("description"), Description); - } - Result->SetBoolField(TEXT("saved"), bSaved); -} - -// ============================================================ -// Phase 5: Material Snapshot/Diff/Restore -// ============================================================ - -// ============================================================ -// HandleSnapshotMaterialGraph — snapshot current material graph state -// ============================================================ - -void FBlueprintMCPServer::HandleSnapshotMaterialGraph(const FJsonObject* Json, FJsonObject* Result) -{ - FString MaterialName = Json->GetStringField(TEXT("material")); - if (MaterialName.IsEmpty()) - { - return MCPUtils::MakeErrorJson(Result, TEXT("Missing required field: material")); - } - - // Load material - UMaterial* Material = UMCPAssetFinder::LoadAsset(MaterialName, Result); - if (!Material) return; - - MCPUtils::EnsureMaterialGraph(Material); - if (!Material->MaterialGraph) - { - return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Material '%s' has no material graph"), *MaterialName)); - } - - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Creating snapshot for material '%s'"), *MaterialName); - - // Build the snapshot - FGraphSnapshot Snapshot; - Snapshot.SnapshotId = GenerateSnapshotId(MaterialName); - Snapshot.BlueprintName = Material->GetName(); - Snapshot.BlueprintPath = Material->GetPathName(); - Snapshot.CreatedAt = FDateTime::Now(); - - // Capture the material graph - FGraphSnapshotData GraphData = CaptureGraphSnapshot(Material->MaterialGraph); - - int32 NodeCount = GraphData.Nodes.Num(); - int32 ConnectionCount = GraphData.Connections.Num(); - - Snapshot.Graphs.Add(TEXT("MaterialGraph"), MoveTemp(GraphData)); - - // Store in material snapshots (separate from blueprint snapshots) - MaterialSnapshots.Add(Snapshot.SnapshotId, Snapshot); - - // Prune old material snapshots - while (MaterialSnapshots.Num() > MaxSnapshots) - { - FString OldestId; - FDateTime OldestTime = FDateTime::MaxValue(); - - for (const auto& Pair : MaterialSnapshots) - { - if (Pair.Value.CreatedAt < OldestTime) - { - OldestTime = Pair.Value.CreatedAt; - OldestId = Pair.Key; - } - } - - if (!OldestId.IsEmpty()) - { - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Pruning old material snapshot '%s'"), *OldestId); - MaterialSnapshots.Remove(OldestId); - } - else - { - break; - } - } - - // Save to disk - SaveSnapshotToDisk(Snapshot.SnapshotId, Snapshot); - - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Material snapshot '%s' created with %d nodes, %d connections"), - *Snapshot.SnapshotId, NodeCount, ConnectionCount); - - Result->SetStringField(TEXT("status"), TEXT("ok")); - Result->SetStringField(TEXT("snapshotId"), Snapshot.SnapshotId); - Result->SetStringField(TEXT("material"), Material->GetName()); - Result->SetNumberField(TEXT("nodeCount"), NodeCount); - Result->SetNumberField(TEXT("connectionCount"), ConnectionCount); -} - -// ============================================================ -// HandleDiffMaterialGraph — diff current material graph against snapshot -// ============================================================ - -void FBlueprintMCPServer::HandleDiffMaterialGraph(const FJsonObject* Json, FJsonObject* Result) -{ - FString MaterialName = Json->GetStringField(TEXT("material")); - FString SnapshotId = Json->GetStringField(TEXT("snapshotId")); - - if (MaterialName.IsEmpty() || SnapshotId.IsEmpty()) - { - return MCPUtils::MakeErrorJson(Result, TEXT("Missing required fields: material, snapshotId")); - } - - // Load snapshot from material snapshots (memory or disk) - FGraphSnapshot* SnapshotPtr = MaterialSnapshots.Find(SnapshotId); - FGraphSnapshot LoadedSnapshot; - if (!SnapshotPtr) - { - if (!LoadSnapshotFromDisk(SnapshotId, LoadedSnapshot)) - { - return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Snapshot '%s' not found in memory or on disk"), *SnapshotId)); - } - SnapshotPtr = &LoadedSnapshot; - } - - // Load material - UMaterial* Material = UMCPAssetFinder::LoadAsset(MaterialName, Result); - if (!Material) return; - - MCPUtils::EnsureMaterialGraph(Material); - if (!Material->MaterialGraph) - { - return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Material '%s' has no material graph"), *MaterialName)); - } - - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Diffing material '%s' against snapshot '%s'"), *MaterialName, *SnapshotId); - - // Capture current state - FGraphSnapshotData CurrentData = CaptureGraphSnapshot(Material->MaterialGraph); - - auto MakeConnKey = [](const FString& SrcGuid, const FString& SrcPin, const FString& TgtGuid, const FString& TgtPin) -> FString - { - return FString::Printf(TEXT("%s|%s|%s|%s"), *SrcGuid, *SrcPin, *TgtGuid, *TgtPin); - }; - - TArray> SeveredArr; - TArray> NewConnsArr; - TArray> MissingNodesArr; - - // Process the MaterialGraph from the snapshot - const FGraphSnapshotData* SnapDataPtr = SnapshotPtr->Graphs.Find(TEXT("MaterialGraph")); - if (!SnapDataPtr) - { - return MCPUtils::MakeErrorJson(Result, TEXT("Snapshot does not contain a MaterialGraph")); - } - - const FGraphSnapshotData& SnapData = *SnapDataPtr; - - // Build node lookup maps - TMap SnapNodeLookup; - for (const FNodeRecord& NR : SnapData.Nodes) - { - SnapNodeLookup.Add(NR.NodeGuid, &NR); - } - - TMap CurNodeLookup; - for (const FNodeRecord& NR : CurrentData.Nodes) - { - CurNodeLookup.Add(NR.NodeGuid, &NR); - } - - // Build connection sets - TSet SnapConnSet; - for (const FPinConnectionRecord& Conn : SnapData.Connections) - { - SnapConnSet.Add(MakeConnKey(Conn.SourceNodeGuid, Conn.SourcePinName, Conn.TargetNodeGuid, Conn.TargetPinName)); - } - - TSet CurrentConnSet; - for (const FPinConnectionRecord& Conn : CurrentData.Connections) - { - CurrentConnSet.Add(MakeConnKey(Conn.SourceNodeGuid, Conn.SourcePinName, Conn.TargetNodeGuid, Conn.TargetPinName)); - } - - // Find severed connections: in snapshot but not in current - for (const FPinConnectionRecord& Conn : SnapData.Connections) - { - FString Key = MakeConnKey(Conn.SourceNodeGuid, Conn.SourcePinName, Conn.TargetNodeGuid, Conn.TargetPinName); - if (!CurrentConnSet.Contains(Key)) - { - TSharedRef SJ = MakeShared(); - SJ->SetStringField(TEXT("sourceNodeGuid"), Conn.SourceNodeGuid); - SJ->SetStringField(TEXT("sourcePinName"), Conn.SourcePinName); - SJ->SetStringField(TEXT("targetNodeGuid"), Conn.TargetNodeGuid); - SJ->SetStringField(TEXT("targetPinName"), Conn.TargetPinName); - - const FNodeRecord** SrcRec = SnapNodeLookup.Find(Conn.SourceNodeGuid); - if (SrcRec) SJ->SetStringField(TEXT("sourceNodeName"), (*SrcRec)->NodeTitle); - const FNodeRecord** TgtRec = SnapNodeLookup.Find(Conn.TargetNodeGuid); - if (TgtRec) SJ->SetStringField(TEXT("targetNodeName"), (*TgtRec)->NodeTitle); - - SeveredArr.Add(MakeShared(SJ)); - } - } - - // Find new connections: in current but not in snapshot - for (const FPinConnectionRecord& Conn : CurrentData.Connections) - { - FString Key = MakeConnKey(Conn.SourceNodeGuid, Conn.SourcePinName, Conn.TargetNodeGuid, Conn.TargetPinName); - if (!SnapConnSet.Contains(Key)) - { - TSharedRef NJ = MakeShared(); - NJ->SetStringField(TEXT("sourceNodeGuid"), Conn.SourceNodeGuid); - NJ->SetStringField(TEXT("sourcePinName"), Conn.SourcePinName); - NJ->SetStringField(TEXT("targetNodeGuid"), Conn.TargetNodeGuid); - NJ->SetStringField(TEXT("targetPinName"), Conn.TargetPinName); - - const FNodeRecord** SrcRec = CurNodeLookup.Find(Conn.SourceNodeGuid); - if (SrcRec) NJ->SetStringField(TEXT("sourceNodeName"), (*SrcRec)->NodeTitle); - const FNodeRecord** TgtRec = CurNodeLookup.Find(Conn.TargetNodeGuid); - if (TgtRec) NJ->SetStringField(TEXT("targetNodeName"), (*TgtRec)->NodeTitle); - - NewConnsArr.Add(MakeShared(NJ)); - } - } - - // Find missing nodes: in snapshot but not in current - for (const FNodeRecord& SnapNode : SnapData.Nodes) - { - const FNodeRecord** CurNodePtr = CurNodeLookup.Find(SnapNode.NodeGuid); - if (!CurNodePtr) - { - TSharedRef MJ = MakeShared(); - MJ->SetStringField(TEXT("nodeGuid"), SnapNode.NodeGuid); - MJ->SetStringField(TEXT("nodeClass"), SnapNode.NodeClass); - MJ->SetStringField(TEXT("nodeTitle"), SnapNode.NodeTitle); - MissingNodesArr.Add(MakeShared(MJ)); - } - } - - // Build result - Result->SetStringField(TEXT("status"), TEXT("ok")); - Result->SetStringField(TEXT("material"), Material->GetName()); - Result->SetStringField(TEXT("snapshotId"), SnapshotId); - Result->SetArrayField(TEXT("severedConnections"), SeveredArr); - Result->SetArrayField(TEXT("newConnections"), NewConnsArr); - Result->SetArrayField(TEXT("missingNodes"), MissingNodesArr); - - TSharedRef Summary = MakeShared(); - Summary->SetNumberField(TEXT("severedConnections"), SeveredArr.Num()); - Summary->SetNumberField(TEXT("newConnections"), NewConnsArr.Num()); - Summary->SetNumberField(TEXT("missingNodes"), MissingNodesArr.Num()); - Result->SetObjectField(TEXT("summary"), Summary); - - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Material diff complete — %d severed, %d new, %d missing nodes"), - SeveredArr.Num(), NewConnsArr.Num(), MissingNodesArr.Num()); -} - -// ============================================================ -// HandleRestoreMaterialGraph — restore material graph connections from snapshot -// ============================================================ - -void FBlueprintMCPServer::HandleRestoreMaterialGraph(const FJsonObject* Json, FJsonObject* Result) -{ - FString MaterialName = Json->GetStringField(TEXT("material")); - FString SnapshotId = Json->GetStringField(TEXT("snapshotId")); - - if (MaterialName.IsEmpty() || SnapshotId.IsEmpty()) - { - return MCPUtils::MakeErrorJson(Result, TEXT("Missing required fields: material, snapshotId")); - } - - bool bDryRun = false; - Json->TryGetBoolField(TEXT("dryRun"), bDryRun); - - // Load snapshot from material snapshots (memory or disk) - FGraphSnapshot* SnapshotPtr = MaterialSnapshots.Find(SnapshotId); - FGraphSnapshot LoadedSnapshot; - if (!SnapshotPtr) - { - if (!LoadSnapshotFromDisk(SnapshotId, LoadedSnapshot)) - { - return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Snapshot '%s' not found in memory or on disk"), *SnapshotId)); - } - SnapshotPtr = &LoadedSnapshot; - } - - // Load material - UMaterial* Material = UMCPAssetFinder::LoadAsset(MaterialName, Result); - if (!Material) return; - - MCPUtils::EnsureMaterialGraph(Material); - if (!Material->MaterialGraph) - { - return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Material '%s' has no material graph"), *MaterialName)); - } - - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Restoring material connections from snapshot '%s' for material '%s' (dryRun=%s)"), - *SnapshotId, *MaterialName, bDryRun ? TEXT("true") : TEXT("false")); - - // Capture current state for comparison - FGraphSnapshotData CurrentData = CaptureGraphSnapshot(Material->MaterialGraph); - - auto MakeConnKey = [](const FString& SrcGuid, const FString& SrcPin, const FString& TgtGuid, const FString& TgtPin) -> FString - { - return FString::Printf(TEXT("%s|%s|%s|%s"), *SrcGuid, *SrcPin, *TgtGuid, *TgtPin); - }; - - // Build current connection set - TSet CurrentConnSet; - for (const FPinConnectionRecord& Conn : CurrentData.Connections) - { - CurrentConnSet.Add(MakeConnKey(Conn.SourceNodeGuid, Conn.SourcePinName, Conn.TargetNodeGuid, Conn.TargetPinName)); - } - - // Build node lookup for the material graph - TMap NodeLookup; - for (UEdGraphNode* Node : Material->MaterialGraph->Nodes) - { - if (Node) - { - NodeLookup.Add(Node->NodeGuid.ToString(), Node); - } - } - - const FGraphSnapshotData* SnapDataPtr = SnapshotPtr->Graphs.Find(TEXT("MaterialGraph")); - if (!SnapDataPtr) - { - return MCPUtils::MakeErrorJson(Result, TEXT("Snapshot does not contain a MaterialGraph")); - } - - int32 Reconnected = 0; - int32 Failed = 0; - TArray> DetailsArr; - - for (const FPinConnectionRecord& Conn : SnapDataPtr->Connections) - { - FString Key = MakeConnKey(Conn.SourceNodeGuid, Conn.SourcePinName, Conn.TargetNodeGuid, Conn.TargetPinName); - if (CurrentConnSet.Contains(Key)) continue; // Still connected, skip - - TSharedRef Detail = MakeShared(); - Detail->SetStringField(TEXT("sourcePinName"), Conn.SourcePinName); - Detail->SetStringField(TEXT("targetPinName"), Conn.TargetPinName); - Detail->SetStringField(TEXT("sourceNodeGuid"), Conn.SourceNodeGuid); - Detail->SetStringField(TEXT("targetNodeGuid"), Conn.TargetNodeGuid); - - // Find source and target nodes - UEdGraphNode** SourceNodePtr = NodeLookup.Find(Conn.SourceNodeGuid); - UEdGraphNode** TargetNodePtr = NodeLookup.Find(Conn.TargetNodeGuid); - - if (!SourceNodePtr || !*SourceNodePtr) - { - Detail->SetStringField(TEXT("result"), TEXT("failed")); - Detail->SetStringField(TEXT("reason"), FString::Printf(TEXT("Source node '%s' no longer exists"), *Conn.SourceNodeGuid)); - Failed++; - DetailsArr.Add(MakeShared(Detail)); - continue; - } - if (!TargetNodePtr || !*TargetNodePtr) - { - Detail->SetStringField(TEXT("result"), TEXT("failed")); - Detail->SetStringField(TEXT("reason"), FString::Printf(TEXT("Target node '%s' no longer exists"), *Conn.TargetNodeGuid)); - Failed++; - DetailsArr.Add(MakeShared(Detail)); - continue; - } - - UEdGraphNode* SourceNode = *SourceNodePtr; - UEdGraphNode* TargetNode = *TargetNodePtr; - - Detail->SetStringField(TEXT("sourceNodeName"), SourceNode->GetNodeTitle(ENodeTitleType::FullTitle).ToString()); - Detail->SetStringField(TEXT("targetNodeName"), TargetNode->GetNodeTitle(ENodeTitleType::FullTitle).ToString()); - - // Find pins - UEdGraphPin* SourcePin = SourceNode->FindPin(FName(*Conn.SourcePinName)); - UEdGraphPin* TargetPin = TargetNode->FindPin(FName(*Conn.TargetPinName)); - - if (!SourcePin) - { - Detail->SetStringField(TEXT("result"), TEXT("failed")); - Detail->SetStringField(TEXT("reason"), FString::Printf(TEXT("Source pin '%s' not found on node"), *Conn.SourcePinName)); - Failed++; - DetailsArr.Add(MakeShared(Detail)); - continue; - } - if (!TargetPin) - { - Detail->SetStringField(TEXT("result"), TEXT("failed")); - Detail->SetStringField(TEXT("reason"), FString::Printf(TEXT("Target pin '%s' not found on node"), *Conn.TargetPinName)); - Failed++; - DetailsArr.Add(MakeShared(Detail)); - continue; - } - - if (bDryRun) - { - Detail->SetStringField(TEXT("result"), TEXT("would_reconnect")); - Reconnected++; - DetailsArr.Add(MakeShared(Detail)); - continue; - } - - // Try to reconnect via the schema - const UEdGraphSchema* Schema = Material->MaterialGraph->GetSchema(); - if (!Schema) - { - Detail->SetStringField(TEXT("result"), TEXT("failed")); - Detail->SetStringField(TEXT("reason"), TEXT("Material graph schema not found")); - Failed++; - DetailsArr.Add(MakeShared(Detail)); - continue; - } - - bool bConnected = Schema->TryCreateConnection(SourcePin, TargetPin); - if (bConnected) - { - Detail->SetStringField(TEXT("result"), TEXT("reconnected")); - Reconnected++; - } - else - { - Detail->SetStringField(TEXT("result"), TEXT("failed")); - Detail->SetStringField(TEXT("reason"), TEXT("TryCreateConnection failed — types may be incompatible")); - Failed++; - } - DetailsArr.Add(MakeShared(Detail)); - } - - // Save if not dry run and we reconnected something - bool bSaved = false; - if (!bDryRun && Reconnected > 0) - { - Material->PreEditChange(nullptr); - Material->PostEditChange(); - bSaved = MCPUtils::SaveMaterialPackage(Material); - } - - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Material restore complete — %d reconnected, %d failed, saved=%s"), - Reconnected, Failed, bSaved ? TEXT("true") : TEXT("false")); - - Result->SetStringField(TEXT("status"), TEXT("ok")); - Result->SetStringField(TEXT("material"), Material->GetName()); - Result->SetStringField(TEXT("snapshotId"), SnapshotId); - Result->SetNumberField(TEXT("reconnected"), Reconnected); - Result->SetNumberField(TEXT("failed"), Failed); - Result->SetArrayField(TEXT("details"), DetailsArr); - Result->SetBoolField(TEXT("saved"), bSaved); - Result->SetBoolField(TEXT("dryRun"), bDryRun); -} diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_MaterialMutation.h b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_MaterialMutation.h new file mode 100644 index 00000000..b39c235b --- /dev/null +++ b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_MaterialMutation.h @@ -0,0 +1,1632 @@ +#pragma once + +#include "CoreMinimal.h" +#include "MCPHandler.h" +#include "MCPAssetFinder.h" +#include "MCPUtils.h" +#include "Materials/Material.h" +#include "MaterialDomain.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/MaterialGraphSchema.h" +#include "Factories/MaterialFactoryNew.h" +#include "Factories/MaterialFunctionFactoryNew.h" +#include "AssetToolsModule.h" +#include "IAssetTools.h" +#include "AssetRegistry/AssetRegistryModule.h" +#include "EdGraph/EdGraph.h" +#include "EdGraph/EdGraphNode.h" +#include "Serialization/JsonReader.h" +#include "Serialization/JsonWriter.h" +#include "Serialization/JsonSerializer.h" +#include "Misc/Guid.h" +#include "Misc/FileHelper.h" +#include "Misc/Paths.h" +#include "UObject/SavePackage.h" +#include "UObject/UObjectIterator.h" +#include "Kismet2/BlueprintEditorUtils.h" +#include "MCPHandlers_MaterialMutation.generated.h" + +// SEH wrapper defined in BlueprintMCPServer.cpp — catches crashes from abstract/invalid expression classes. +// Wraps the entire creation + registration + PostEditChange flow so that if the expression crashes +// (e.g. UMaterialExpressionParameter), it cleans up and returns -1 instead of terminating the process. +#if PLATFORM_WINDOWS +extern int32 TryAddMaterialExpressionSEH( + UObject* Owner, UClass* ExprClass, UMaterial* Material, UMaterialFunction* MatFunc, + int32 PosX, int32 PosY, UMaterialExpression** OutExpr); +#endif + +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + +UCLASS(meta=(ToolName="create_material_asset")) +class UMCPHandler_CreateMaterial : public UObject, public IMCPHandler +{ + GENERATED_BODY() + +public: + UPROPERTY(meta=(Description="Name for the new material asset")) + FString Name; + + UPROPERTY(meta=(Description="Package path where the asset will be created (must start with /Game)")) + FString PackagePath; + + UPROPERTY(meta=(Optional, Description="Material domain: Surface, DeferredDecal, LightFunction, Volume, PostProcess, UI")) + FString Domain; + + UPROPERTY(meta=(Optional, Description="Blend mode: Opaque, Masked, Translucent, Additive, Modulate")) + FString BlendMode; + + UPROPERTY(meta=(Optional, Description="Whether the material is two-sided")) + bool TwoSided = false; + + virtual FString GetDescription() const override + { + return TEXT("Create a new UMaterial asset with optional domain, blend mode, and two-sided settings."); + } + + virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override + { + if (!PackagePath.StartsWith(TEXT("/Game"))) + { + return MCPUtils::MakeErrorJson(Result, TEXT("packagePath must start with '/Game'")); + } + + // Check if asset already exists + FString FullAssetPath = PackagePath / Name; + if (UMCPAssetFinder::FindAsset(UMaterial::StaticClass(), Name) || UMCPAssetFinder::FindAsset(UMaterial::StaticClass(), FullAssetPath)) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf( + TEXT("Material '%s' already exists. Use a different name or delete the existing asset first."), + *Name)); + } + + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Creating Material '%s' in '%s'"), *Name, *PackagePath); + + // Create via IAssetTools + factory + IAssetTools& AssetTools = FModuleManager::LoadModuleChecked("AssetTools").Get(); + UMaterialFactoryNew* Factory = NewObject(); + UObject* NewAsset = AssetTools.CreateAsset(Name, PackagePath, UMaterial::StaticClass(), Factory); + + if (!NewAsset) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Failed to create Material '%s' in '%s'"), *Name, *PackagePath)); + } + + UMaterial* MaterialObj = Cast(NewAsset); + if (!MaterialObj) + { + return MCPUtils::MakeErrorJson(Result, TEXT("Created asset is not a UMaterial")); + } + + // Apply optional properties + bool bHasTwoSided = Json->HasField(TEXT("twoSided")); + + MaterialObj->PreEditChange(nullptr); + + // Parse domain + if (!Domain.IsEmpty()) + { + if (Domain == TEXT("Surface")) + MaterialObj->MaterialDomain = MD_Surface; + else if (Domain == TEXT("DeferredDecal")) + MaterialObj->MaterialDomain = MD_DeferredDecal; + else if (Domain == TEXT("LightFunction")) + MaterialObj->MaterialDomain = MD_LightFunction; + else if (Domain == TEXT("Volume")) + MaterialObj->MaterialDomain = MD_Volume; + else if (Domain == TEXT("PostProcess")) + MaterialObj->MaterialDomain = MD_PostProcess; + else if (Domain == TEXT("UI")) + MaterialObj->MaterialDomain = MD_UI; + } + + // Parse blend mode + if (!BlendMode.IsEmpty()) + { + if (BlendMode == TEXT("Opaque")) + MaterialObj->BlendMode = BLEND_Opaque; + else if (BlendMode == TEXT("Masked")) + MaterialObj->BlendMode = BLEND_Masked; + else if (BlendMode == TEXT("Translucent")) + MaterialObj->BlendMode = BLEND_Translucent; + else if (BlendMode == TEXT("Additive")) + MaterialObj->BlendMode = BLEND_Additive; + else if (BlendMode == TEXT("Modulate")) + MaterialObj->BlendMode = BLEND_Modulate; + } + + if (bHasTwoSided) + { + MaterialObj->TwoSided = TwoSided; + } + + MaterialObj->PostEditChange(); + + // Save + bool bSaved = MCPUtils::SaveMaterialPackage(MaterialObj); + + + // Map domain back to string for response + auto DomainToString = [](EMaterialDomain InDomain) -> FString + { + switch (InDomain) + { + case MD_Surface: return TEXT("Surface"); + case MD_DeferredDecal: return TEXT("DeferredDecal"); + case MD_LightFunction: return TEXT("LightFunction"); + case MD_Volume: return TEXT("Volume"); + case MD_PostProcess: return TEXT("PostProcess"); + case MD_UI: return TEXT("UI"); + default: return TEXT("Surface"); + } + }; + + auto BlendModeToString = [](EBlendMode Mode) -> FString + { + switch (Mode) + { + case BLEND_Opaque: return TEXT("Opaque"); + case BLEND_Masked: return TEXT("Masked"); + case BLEND_Translucent: return TEXT("Translucent"); + case BLEND_Additive: return TEXT("Additive"); + case BLEND_Modulate: return TEXT("Modulate"); + default: return TEXT("Opaque"); + } + }; + + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Created Material '%s' (saved: %s)"), + *Name, bSaved ? TEXT("true") : TEXT("false")); + + Result->SetBoolField(TEXT("success"), true); + Result->SetStringField(TEXT("name"), Name); + Result->SetStringField(TEXT("path"), MaterialObj->GetPathName()); + Result->SetStringField(TEXT("domain"), DomainToString(MaterialObj->MaterialDomain)); + Result->SetStringField(TEXT("blendMode"), BlendModeToString(MaterialObj->BlendMode)); + Result->SetBoolField(TEXT("twoSided"), MaterialObj->TwoSided != 0); + Result->SetBoolField(TEXT("saved"), bSaved); + } +}; + +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + +UCLASS(meta=(ToolName="set_material_property")) +class UMCPHandler_SetMaterialProperty : public UObject, public IMCPHandler +{ + GENERATED_BODY() + +public: + UPROPERTY(meta=(Description="Material name or package path")) + FString Material; + + UPROPERTY(meta=(Description="Property name to set (domain, blendMode, twoSided, shadingModel, opacity, opacityMaskClipValue, bUsedWithSkeletalMesh, bUsedWithMorphTargets, bUsedWithNiagaraSprites, ditheredLODTransition, bAllowNegativeEmissiveColor)")) + FString Property; + + UPROPERTY(meta=(Optional, Description="If true, preview the change without applying it")) + bool DryRun = false; + + virtual FString GetDescription() const override + { + return TEXT("Set a top-level material property such as domain, blend mode, shading model, or usage flags. " + "The 'value' field in the JSON payload provides the new value."); + } + + virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override + { + if (!Json->HasField(TEXT("value"))) + { + return MCPUtils::MakeErrorJson(Result, TEXT("Missing required field: value")); + } + + // Load material + UMaterial* MaterialObj = UMCPAssetFinder::LoadAsset(Material, Result); + if (!MaterialObj) return; + + FString OldValue; + FString NewValue; + + // Helper lambdas for converting enum values to strings + auto DomainToString = [](EMaterialDomain Domain) -> FString + { + switch (Domain) + { + case MD_Surface: return TEXT("Surface"); + case MD_DeferredDecal: return TEXT("DeferredDecal"); + case MD_LightFunction: return TEXT("LightFunction"); + case MD_Volume: return TEXT("Volume"); + case MD_PostProcess: return TEXT("PostProcess"); + case MD_UI: return TEXT("UI"); + default: return TEXT("Unknown"); + } + }; + + auto BlendModeToString = [](EBlendMode Mode) -> FString + { + switch (Mode) + { + case BLEND_Opaque: return TEXT("Opaque"); + case BLEND_Masked: return TEXT("Masked"); + case BLEND_Translucent: return TEXT("Translucent"); + case BLEND_Additive: return TEXT("Additive"); + case BLEND_Modulate: return TEXT("Modulate"); + default: return TEXT("Unknown"); + } + }; + + auto ShadingModelToString = [](EMaterialShadingModel Model) -> FString + { + switch (Model) + { + case MSM_Unlit: return TEXT("Unlit"); + case MSM_DefaultLit: return TEXT("DefaultLit"); + case MSM_Subsurface: return TEXT("Subsurface"); + case MSM_PreintegratedSkin: return TEXT("PreintegratedSkin"); + case MSM_ClearCoat: return TEXT("ClearCoat"); + case MSM_SubsurfaceProfile: return TEXT("SubsurfaceProfile"); + case MSM_TwoSidedFoliage: return TEXT("TwoSidedFoliage"); + case MSM_Hair: return TEXT("Hair"); + case MSM_Cloth: return TEXT("Cloth"); + case MSM_Eye: return TEXT("Eye"); + default: return TEXT("DefaultLit"); + } + }; + + if (Property == TEXT("domain")) + { + FString ValueStr = Json->GetStringField(TEXT("value")); + OldValue = DomainToString(MaterialObj->MaterialDomain); + + EMaterialDomain NewDomain = MaterialObj->MaterialDomain; + if (ValueStr == TEXT("Surface")) NewDomain = MD_Surface; + else if (ValueStr == TEXT("DeferredDecal")) NewDomain = MD_DeferredDecal; + else if (ValueStr == TEXT("LightFunction")) NewDomain = MD_LightFunction; + else if (ValueStr == TEXT("Volume")) NewDomain = MD_Volume; + else if (ValueStr == TEXT("PostProcess")) NewDomain = MD_PostProcess; + else if (ValueStr == TEXT("UI")) NewDomain = MD_UI; + else + { + return MCPUtils::MakeErrorJson(Result, FString::Printf( + TEXT("Invalid domain '%s'. Valid values: Surface, DeferredDecal, LightFunction, Volume, PostProcess, UI"), + *ValueStr)); + } + + NewValue = ValueStr; + + if (!DryRun) + { + MaterialObj->PreEditChange(nullptr); + MaterialObj->MaterialDomain = NewDomain; + MaterialObj->PostEditChange(); + } + } + else if (Property == TEXT("blendMode")) + { + FString ValueStr = Json->GetStringField(TEXT("value")); + OldValue = BlendModeToString(MaterialObj->BlendMode); + + EBlendMode NewBlend = MaterialObj->BlendMode; + if (ValueStr == TEXT("Opaque")) NewBlend = BLEND_Opaque; + else if (ValueStr == TEXT("Masked")) NewBlend = BLEND_Masked; + else if (ValueStr == TEXT("Translucent")) NewBlend = BLEND_Translucent; + else if (ValueStr == TEXT("Additive")) NewBlend = BLEND_Additive; + else if (ValueStr == TEXT("Modulate")) NewBlend = BLEND_Modulate; + else + { + return MCPUtils::MakeErrorJson(Result, FString::Printf( + TEXT("Invalid blendMode '%s'. Valid values: Opaque, Masked, Translucent, Additive, Modulate"), + *ValueStr)); + } + + NewValue = ValueStr; + + if (!DryRun) + { + MaterialObj->PreEditChange(nullptr); + MaterialObj->BlendMode = NewBlend; + MaterialObj->PostEditChange(); + } + } + else if (Property == TEXT("twoSided")) + { + bool bValue = Json->GetBoolField(TEXT("value")); + OldValue = MaterialObj->TwoSided ? TEXT("true") : TEXT("false"); + NewValue = bValue ? TEXT("true") : TEXT("false"); + + if (!DryRun) + { + MaterialObj->PreEditChange(nullptr); + MaterialObj->TwoSided = bValue ? 1 : 0; + MaterialObj->PostEditChange(); + } + } + else if (Property == TEXT("shadingModel")) + { + FString ValueStr = Json->GetStringField(TEXT("value")); + OldValue = ShadingModelToString(MaterialObj->GetShadingModels().GetFirstShadingModel()); + + EMaterialShadingModel NewModel = MSM_DefaultLit; + if (ValueStr == TEXT("Unlit")) NewModel = MSM_Unlit; + else if (ValueStr == TEXT("DefaultLit")) NewModel = MSM_DefaultLit; + else if (ValueStr == TEXT("Subsurface")) NewModel = MSM_Subsurface; + else if (ValueStr == TEXT("PreintegratedSkin")) NewModel = MSM_PreintegratedSkin; + else if (ValueStr == TEXT("ClearCoat")) NewModel = MSM_ClearCoat; + else if (ValueStr == TEXT("SubsurfaceProfile")) NewModel = MSM_SubsurfaceProfile; + else if (ValueStr == TEXT("TwoSidedFoliage")) NewModel = MSM_TwoSidedFoliage; + else if (ValueStr == TEXT("Hair")) NewModel = MSM_Hair; + else if (ValueStr == TEXT("Cloth")) NewModel = MSM_Cloth; + else if (ValueStr == TEXT("Eye")) NewModel = MSM_Eye; + else + { + return MCPUtils::MakeErrorJson(Result, FString::Printf( + TEXT("Invalid shadingModel '%s'. Valid values: Unlit, DefaultLit, Subsurface, PreintegratedSkin, ClearCoat, SubsurfaceProfile, TwoSidedFoliage, Hair, Cloth, Eye"), + *ValueStr)); + } + + NewValue = ValueStr; + + if (!DryRun) + { + MaterialObj->PreEditChange(nullptr); + MaterialObj->SetShadingModel(NewModel); + MaterialObj->PostEditChange(); + } + } + else if (Property == TEXT("opacity") || Property == TEXT("opacityMaskClipValue")) + { + double OpacityValue = Json->GetNumberField(TEXT("value")); + OldValue = FString::Printf(TEXT("%f"), MaterialObj->OpacityMaskClipValue); + NewValue = FString::Printf(TEXT("%f"), OpacityValue); + + if (!DryRun) + { + MaterialObj->PreEditChange(nullptr); + MaterialObj->OpacityMaskClipValue = (float)OpacityValue; + MaterialObj->PostEditChange(); + } + } + else if (Property == TEXT("bUsedWithSkeletalMesh")) + { + bool bValue = Json->GetBoolField(TEXT("value")); + OldValue = MaterialObj->bUsedWithSkeletalMesh ? TEXT("true") : TEXT("false"); + NewValue = bValue ? TEXT("true") : TEXT("false"); + + if (!DryRun) + { + MaterialObj->PreEditChange(nullptr); + MaterialObj->bUsedWithSkeletalMesh = bValue ? 1 : 0; + MaterialObj->PostEditChange(); + } + } + else if (Property == TEXT("bUsedWithMorphTargets")) + { + bool bValue = Json->GetBoolField(TEXT("value")); + OldValue = MaterialObj->bUsedWithMorphTargets ? TEXT("true") : TEXT("false"); + NewValue = bValue ? TEXT("true") : TEXT("false"); + + if (!DryRun) + { + MaterialObj->PreEditChange(nullptr); + MaterialObj->bUsedWithMorphTargets = bValue ? 1 : 0; + MaterialObj->PostEditChange(); + } + } + else if (Property == TEXT("bUsedWithNiagaraSprites")) + { + bool bValue = Json->GetBoolField(TEXT("value")); + OldValue = MaterialObj->bUsedWithNiagaraSprites ? TEXT("true") : TEXT("false"); + NewValue = bValue ? TEXT("true") : TEXT("false"); + + if (!DryRun) + { + MaterialObj->PreEditChange(nullptr); + MaterialObj->bUsedWithNiagaraSprites = bValue ? 1 : 0; + MaterialObj->PostEditChange(); + } + } + else if (Property == TEXT("ditheredLODTransition") || Property == TEXT("DitheredLODTransition")) + { + bool bValue = Json->GetBoolField(TEXT("value")); + OldValue = MaterialObj->DitheredLODTransition ? TEXT("true") : TEXT("false"); + NewValue = bValue ? TEXT("true") : TEXT("false"); + + if (!DryRun) + { + MaterialObj->PreEditChange(nullptr); + MaterialObj->DitheredLODTransition = bValue ? 1 : 0; + MaterialObj->PostEditChange(); + } + } + else if (Property == TEXT("bAllowNegativeEmissiveColor")) + { + bool bValue = Json->GetBoolField(TEXT("value")); + OldValue = MaterialObj->bAllowNegativeEmissiveColor ? TEXT("true") : TEXT("false"); + NewValue = bValue ? TEXT("true") : TEXT("false"); + + if (!DryRun) + { + MaterialObj->PreEditChange(nullptr); + MaterialObj->bAllowNegativeEmissiveColor = bValue ? 1 : 0; + MaterialObj->PostEditChange(); + } + } + else + { + return MCPUtils::MakeErrorJson(Result, FString::Printf( + TEXT("Unknown property '%s'. Valid properties: domain, blendMode, twoSided, shadingModel, opacity, " + "opacityMaskClipValue, bUsedWithSkeletalMesh, bUsedWithMorphTargets, bUsedWithNiagaraSprites, " + "ditheredLODTransition, bAllowNegativeEmissiveColor"), + *Property)); + } + + // Save if not dry run + bool bSaved = false; + if (!DryRun) + { + bSaved = MCPUtils::SaveMaterialPackage(MaterialObj); + } + + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: %sSet material property '%s' on '%s': '%s' -> '%s'"), + DryRun ? TEXT("[DRY RUN] ") : TEXT(""), + *Property, *Material, *OldValue, *NewValue); + + Result->SetBoolField(TEXT("success"), true); + Result->SetStringField(TEXT("material"), MaterialObj->GetName()); + Result->SetStringField(TEXT("property"), Property); + Result->SetStringField(TEXT("oldValue"), OldValue); + Result->SetStringField(TEXT("newValue"), NewValue); + Result->SetBoolField(TEXT("dryRun"), DryRun); + if (!DryRun) + { + Result->SetBoolField(TEXT("saved"), bSaved); + } + } +}; + +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + +UCLASS(meta=(ToolName="add_material_expression")) +class UMCPHandler_AddMaterialExpression : public UObject, public IMCPHandler +{ + GENERATED_BODY() + +public: + UPROPERTY(meta=(Optional, Description="Material name or package path (specify this or materialFunction, not both)")) + FString Material; + + UPROPERTY(meta=(Optional, Description="Material function name or package path (specify this or material, not both)")) + FString MaterialFunction; + + UPROPERTY(meta=(Description="Expression class name without 'MaterialExpression' prefix (e.g. 'Constant', 'ScalarParameter', 'Add', 'Multiply', 'Lerp')")) + FString ExpressionClass; + + UPROPERTY(meta=(Optional, Description="X position in the material graph editor")) + int32 PosX = 0; + + UPROPERTY(meta=(Optional, Description="Y position in the material graph editor")) + int32 PosY = 0; + + UPROPERTY(meta=(Optional, Description="If true, preview the change without applying it")) + bool DryRun = false; + + virtual FString GetDescription() const override + { + return TEXT("Add a new expression node to a material or material function graph."); + } + + virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override + { + if (Material.IsEmpty() && MaterialFunction.IsEmpty()) + { + return MCPUtils::MakeErrorJson(Result, TEXT("Missing required field: 'material' or 'materialFunction'")); + } + + // Map string class name to UClass via dynamic lookup + UClass* ExprClass = nullptr; + + // Convenience aliases for backward compatibility + static TMap Aliases = { + {TEXT("Lerp"), TEXT("LinearInterpolate")}, + }; + + FString LookupName = ExpressionClass; + if (const FString* Alias = Aliases.Find(ExpressionClass)) + { + LookupName = *Alias; + } + + // Dynamic lookup: find UMaterialExpression via UClass iteration + FString FullClassName = FString::Printf(TEXT("MaterialExpression%s"), *LookupName); + for (TObjectIterator It; It; ++It) + { + if (It->GetName() == FullClassName && It->IsChildOf(UMaterialExpression::StaticClass())) + { + ExprClass = *It; + break; + } + } + + if (!ExprClass) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf( + TEXT("Unknown expression class '%s'. Use the UMaterialExpression subclass name without the 'MaterialExpression' prefix " + "(e.g. 'Constant', 'ScalarParameter', 'Add', 'Multiply', 'Lerp', 'Subtract', 'Fresnel', 'Comment', etc.)"), + *ExpressionClass)); + } + if (ExprClass->HasAnyClassFlags(CLASS_Abstract)) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf( + TEXT("Expression class '%s' is abstract and cannot be instantiated."), *ExpressionClass)); + } + + // Load material or material function + UMaterial* MaterialObj = nullptr; + UMaterialFunction* MatFunc = nullptr; + UObject* Owner = nullptr; + FString AssetDisplayName; + + if (!MaterialFunction.IsEmpty()) + { + if (!Material.IsEmpty()) + { + return MCPUtils::MakeErrorJson(Result, TEXT("Specify either 'material' or 'materialFunction', not both")); + } + MatFunc = UMCPAssetFinder::LoadAsset(MaterialFunction, Result); + if (!MatFunc) return; + Owner = MatFunc; + AssetDisplayName = MatFunc->GetName(); + } + else + { + MaterialObj = UMCPAssetFinder::LoadAsset(Material, Result); + if (!MaterialObj) return; + Owner = MaterialObj; + AssetDisplayName = MaterialObj->GetName(); + } + + if (DryRun) + { + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: [DRY RUN] Would add expression '%s' to '%s' at (%d, %d)"), + *ExpressionClass, *AssetDisplayName, PosX, PosY); + + Result->SetBoolField(TEXT("success"), true); + Result->SetBoolField(TEXT("dryRun"), true); + Result->SetStringField(TEXT("material"), AssetDisplayName); + Result->SetStringField(TEXT("expressionClass"), ExpressionClass); + Result->SetNumberField(TEXT("posX"), PosX); + Result->SetNumberField(TEXT("posY"), PosY); + return; + } + + // Ensure the MaterialGraph exists (commandlet mode doesn't auto-create it) + if (MaterialObj) MCPUtils::EnsureMaterialGraph(MaterialObj); + + // Create, register, and PostEditChange the expression — all inside an SEH wrapper because + // some classes (e.g. UMaterialExpressionParameter) lack CLASS_Abstract but crash during + // PostEditChange. The SEH wrapper cleans up the bad expression on crash. + UMaterialExpression* NewExpr = nullptr; +#if PLATFORM_WINDOWS + int32 CreateResult = TryAddMaterialExpressionSEH(Owner, ExprClass, MaterialObj, MatFunc, PosX, PosY, &NewExpr); + if (CreateResult != 0 || !NewExpr) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf( + TEXT("Expression class '%s' cannot be instantiated (may be abstract or have internal errors)."), + *ExpressionClass)); + } +#else + NewExpr = NewObject(Owner, ExprClass); + if (!NewExpr) + { + return MCPUtils::MakeErrorJson(Result, TEXT("Failed to create material expression object")); + } + NewExpr->MaterialExpressionEditorX = PosX; + NewExpr->MaterialExpressionEditorY = PosY; + if (MaterialObj) + { + MaterialObj->GetExpressionCollection().AddExpression(NewExpr); + if (MaterialObj->MaterialGraph) + { + MaterialObj->MaterialGraph->RebuildGraph(); + } + MaterialObj->PreEditChange(nullptr); + MaterialObj->PostEditChange(); + MaterialObj->MarkPackageDirty(); + } + else if (MatFunc) + { + MatFunc->GetExpressionCollection().AddExpression(NewExpr); + MatFunc->PreEditChange(nullptr); + MatFunc->PostEditChange(); + MatFunc->MarkPackageDirty(); + } +#endif + + // Save + bool bSaved = MaterialObj ? MCPUtils::SaveMaterialPackage(MaterialObj) : MCPUtils::SaveGenericPackage(MatFunc); + + // Find the node GUID from the material graph (only for materials) + FString NodeGuid; + if (MaterialObj && MaterialObj->MaterialGraph) + { + for (UEdGraphNode* Node : MaterialObj->MaterialGraph->Nodes) + { + UMaterialGraphNode* MatNode = Cast(Node); + if (MatNode && MatNode->MaterialExpression == NewExpr) + { + NodeGuid = Node->NodeGuid.ToString(); + break; + } + } + } + + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Added expression '%s' to '%s' (nodeId: %s, saved: %s)"), + *ExpressionClass, *AssetDisplayName, *NodeGuid, bSaved ? TEXT("true") : TEXT("false")); + + // Serialize the expression details + TSharedPtr ExprDetails = MCPUtils::SerializeMaterialExpression(NewExpr); + + Result->SetBoolField(TEXT("success"), true); + Result->SetStringField(TEXT("material"), AssetDisplayName); + Result->SetStringField(TEXT("expressionClass"), ExpressionClass); + Result->SetStringField(TEXT("nodeId"), NodeGuid); + Result->SetNumberField(TEXT("posX"), PosX); + Result->SetNumberField(TEXT("posY"), PosY); + if (ExprDetails.IsValid()) + { + Result->SetObjectField(TEXT("expression"), ExprDetails); + } + Result->SetBoolField(TEXT("saved"), bSaved); + } +}; + +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + +UCLASS(meta=(ToolName="delete_material_expression")) +class UMCPHandler_DeleteMaterialExpression : public UObject, public IMCPHandler +{ + GENERATED_BODY() + +public: + UPROPERTY(meta=(Optional, Description="Material name or package path (specify this or materialFunction)")) + FString Material; + + UPROPERTY(meta=(Optional, Description="Material function name or package path (specify this or material)")) + FString MaterialFunction; + + UPROPERTY(meta=(Description="Node GUID of the expression to delete")) + FString Node; + + UPROPERTY(meta=(Optional, Description="If true, preview the change without applying it")) + bool DryRun = false; + + virtual FString GetDescription() const override + { + return TEXT("Remove an expression node from a material or material function graph."); + } + + virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override + { + if (Material.IsEmpty() && MaterialFunction.IsEmpty()) + { + return MCPUtils::MakeErrorJson(Result, TEXT("Missing required field: 'material' or 'materialFunction'")); + } + + // Load material or material function + UMaterial* MaterialObj = nullptr; + UMaterialFunction* MatFunc = nullptr; + FString AssetDisplayName; + + if (!MaterialFunction.IsEmpty()) + { + MatFunc = UMCPAssetFinder::LoadAsset(MaterialFunction, Result); + if (!MatFunc) return; + AssetDisplayName = MatFunc->GetName(); + } + else + { + MaterialObj = UMCPAssetFinder::LoadAsset(Material, Result); + if (!MaterialObj) return; + AssetDisplayName = MaterialObj->GetName(); + } + + // For materials, we need the graph to find nodes by GUID + if (MaterialObj) MCPUtils::EnsureMaterialGraph(MaterialObj); + UEdGraph* Graph = MaterialObj ? (UEdGraph*)MaterialObj->MaterialGraph : (MatFunc ? MatFunc->MaterialGraph : nullptr); + if (!Graph) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("'%s' has no material graph"), *AssetDisplayName)); + } + + // Find the node by GUID + UMaterialGraphNode* TargetMatNode = nullptr; + for (UEdGraphNode* GraphNode : Graph->Nodes) + { + if (!GraphNode) continue; + if (GraphNode->NodeGuid.ToString() == Node) + { + TargetMatNode = Cast(GraphNode); + break; + } + } + + if (!TargetMatNode) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Node '%s' not found in material graph"), *Node)); + } + + if (!TargetMatNode->MaterialExpression) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Node '%s' has no associated material expression"), *Node)); + } + + // Capture info before deletion + FString DeletedNodeTitle = TargetMatNode->GetNodeTitle(ENodeTitleType::FullTitle).ToString(); + FString DeletedExprClass = TargetMatNode->MaterialExpression->GetClass()->GetName(); + + if (DryRun) + { + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: [DRY RUN] Would delete expression '%s' (nodeId: %s) from '%s'"), + *DeletedExprClass, *Node, *AssetDisplayName); + + Result->SetBoolField(TEXT("success"), true); + Result->SetBoolField(TEXT("dryRun"), true); + Result->SetStringField(TEXT("material"), AssetDisplayName); + Result->SetStringField(TEXT("deletedNode"), Node); + Result->SetStringField(TEXT("deletedNodeTitle"), DeletedNodeTitle); + Result->SetStringField(TEXT("deletedExpressionClass"), DeletedExprClass); + return; + } + + // Remove the expression + UMaterialExpression* ExprToRemove = TargetMatNode->MaterialExpression; + if (MaterialObj) + { + MaterialObj->GetExpressionCollection().RemoveExpression(ExprToRemove); + } + else + { + MatFunc->GetExpressionCollection().RemoveExpression(ExprToRemove); + } + ExprToRemove->MarkAsGarbage(); + + // Rebuild graph + Graph->NotifyGraphChanged(); + + UObject* Asset = MaterialObj ? (UObject*)MaterialObj : (UObject*)MatFunc; + Asset->PreEditChange(nullptr); + Asset->PostEditChange(); + Asset->MarkPackageDirty(); + + // Save + bool bSaved = MaterialObj ? MCPUtils::SaveMaterialPackage(MaterialObj) : MCPUtils::SaveGenericPackage(MatFunc); + + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Deleted expression '%s' (nodeId: %s) from '%s' (saved: %s)"), + *DeletedExprClass, *Node, *AssetDisplayName, bSaved ? TEXT("true") : TEXT("false")); + + Result->SetBoolField(TEXT("success"), true); + Result->SetStringField(TEXT("material"), AssetDisplayName); + Result->SetStringField(TEXT("deletedNode"), Node); + Result->SetStringField(TEXT("deletedNodeTitle"), DeletedNodeTitle); + Result->SetStringField(TEXT("deletedExpressionClass"), DeletedExprClass); + Result->SetBoolField(TEXT("saved"), bSaved); + } +}; + +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + +UCLASS(meta=(ToolName="connect_material_expression_pins")) +class UMCPHandler_ConnectMaterialPins : public UObject, public IMCPHandler +{ + GENERATED_BODY() + +public: + UPROPERTY(meta=(Optional, Description="Material name or package path (specify this or materialFunction)")) + FString Material; + + UPROPERTY(meta=(Optional, Description="Material function name or package path (specify this or material)")) + FString MaterialFunction; + + UPROPERTY(meta=(Description="Node GUID of the source (output) node")) + FString SourceNode; + + UPROPERTY(meta=(Description="Pin name on the source node")) + FString SourcePinName; + + UPROPERTY(meta=(Description="Node GUID of the target (input) node")) + FString TargetNode; + + UPROPERTY(meta=(Description="Pin name on the target node")) + FString TargetPinName; + + UPROPERTY(meta=(Optional, Description="If true, preview the change without applying it")) + bool DryRun = false; + + virtual FString GetDescription() const override + { + return TEXT("Connect two pins in a material or material function graph."); + } + + virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override + { + if (Material.IsEmpty() && MaterialFunction.IsEmpty()) + { + return MCPUtils::MakeErrorJson(Result, TEXT("Missing required field: 'material' or 'materialFunction'")); + } + + // Load material or material function + UMaterial* MaterialObj = nullptr; + UMaterialFunction* MatFunc = nullptr; + FString AssetDisplayName; + + if (!MaterialFunction.IsEmpty()) + { + MatFunc = UMCPAssetFinder::LoadAsset(MaterialFunction, Result); + if (!MatFunc) return; + AssetDisplayName = MatFunc->GetName(); + } + else + { + MaterialObj = UMCPAssetFinder::LoadAsset(Material, Result); + if (!MaterialObj) return; + AssetDisplayName = MaterialObj->GetName(); + } + + if (MaterialObj) MCPUtils::EnsureMaterialGraph(MaterialObj); + UEdGraph* Graph = MaterialObj ? (UEdGraph*)MaterialObj->MaterialGraph : (MatFunc ? MatFunc->MaterialGraph : nullptr); + if (!Graph) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("'%s' has no material graph"), *AssetDisplayName)); + } + + // Find source and target nodes by GUID + UEdGraphNode* SourceGraphNode = nullptr; + UEdGraphNode* TargetGraphNode = nullptr; + + for (UEdGraphNode* GraphNode : Graph->Nodes) + { + if (!GraphNode) continue; + if (GraphNode->NodeGuid.ToString() == SourceNode) + SourceGraphNode = GraphNode; + if (GraphNode->NodeGuid.ToString() == TargetNode) + TargetGraphNode = GraphNode; + if (SourceGraphNode && TargetGraphNode) + break; + } + + if (!SourceGraphNode) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Source node '%s' not found in material graph"), *SourceNode)); + } + if (!TargetGraphNode) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Target node '%s' not found in material graph"), *TargetNode)); + } + + // Find pins + UEdGraphPin* SourcePin = SourceGraphNode->FindPin(FName(*SourcePinName)); + if (!SourcePin) + { + // List available pins for debugging + TArray> PinNames; + for (UEdGraphPin* P : SourceGraphNode->Pins) + { + if (P) PinNames.Add(MakeShared( + FString::Printf(TEXT("%s (%s)"), *P->PinName.ToString(), + P->Direction == EGPD_Input ? TEXT("Input") : TEXT("Output")))); + } + MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Source pin '%s' not found on node '%s'"), + *SourcePinName, *SourceNode)); + Result->SetArrayField(TEXT("availablePins"), PinNames); + return; + } + + UEdGraphPin* TargetPin = TargetGraphNode->FindPin(FName(*TargetPinName)); + if (!TargetPin) + { + TArray> PinNames; + for (UEdGraphPin* P : TargetGraphNode->Pins) + { + if (P) PinNames.Add(MakeShared( + FString::Printf(TEXT("%s (%s)"), *P->PinName.ToString(), + P->Direction == EGPD_Input ? TEXT("Input") : TEXT("Output")))); + } + MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Target pin '%s' not found on node '%s'"), + *TargetPinName, *TargetNode)); + Result->SetArrayField(TEXT("availablePins"), PinNames); + return; + } + + if (DryRun) + { + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: [DRY RUN] Would connect %s.%s -> %s.%s in '%s'"), + *SourceNode, *SourcePinName, *TargetNode, *TargetPinName, *AssetDisplayName); + + Result->SetBoolField(TEXT("success"), true); + Result->SetBoolField(TEXT("dryRun"), true); + Result->SetBoolField(TEXT("connected"), false); + Result->SetStringField(TEXT("material"), AssetDisplayName); + return; + } + + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Connecting %s.%s -> %s.%s in '%s'"), + *SourceNode, *SourcePinName, *TargetNode, *TargetPinName, *AssetDisplayName); + + // Try to connect via the schema + const UEdGraphSchema* Schema = Graph->GetSchema(); + if (!Schema) + { + return MCPUtils::MakeErrorJson(Result, TEXT("Material graph schema not found")); + } + + bool bConnected = Schema->TryCreateConnection(SourcePin, TargetPin); + + Result->SetBoolField(TEXT("success"), bConnected); + Result->SetBoolField(TEXT("connected"), bConnected); + Result->SetStringField(TEXT("material"), AssetDisplayName); + + if (!bConnected) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf( + TEXT("Cannot connect %s.%s to %s.%s — types may be incompatible"), + *SourceNode, *SourcePinName, *TargetNode, *TargetPinName)); + } + + // Save + UObject* Asset = MaterialObj ? (UObject*)MaterialObj : (UObject*)MatFunc; + Asset->PreEditChange(nullptr); + Asset->PostEditChange(); + bool bSaved = MaterialObj ? MCPUtils::SaveMaterialPackage(MaterialObj) : MCPUtils::SaveGenericPackage(MatFunc); + Result->SetBoolField(TEXT("saved"), bSaved); + } +}; + +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + +UCLASS(meta=(ToolName="disconnect_material_expression_pin")) +class UMCPHandler_DisconnectMaterialPin : public UObject, public IMCPHandler +{ + GENERATED_BODY() + +public: + UPROPERTY(meta=(Optional, Description="Material name or package path (specify this or materialFunction)")) + FString Material; + + UPROPERTY(meta=(Optional, Description="Material function name or package path (specify this or material)")) + FString MaterialFunction; + + UPROPERTY(meta=(Description="Node GUID of the node whose pin to disconnect")) + FString Node; + + UPROPERTY(meta=(Description="Pin name to disconnect")) + FString PinName; + + UPROPERTY(meta=(Optional, Description="If true, preview the change without applying it")) + bool DryRun = false; + + virtual FString GetDescription() const override + { + return TEXT("Break all connections on a specific pin in a material or material function graph."); + } + + virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override + { + if (Material.IsEmpty() && MaterialFunction.IsEmpty()) + { + return MCPUtils::MakeErrorJson(Result, TEXT("Missing required field: 'material' or 'materialFunction'")); + } + + // Load material or material function + UMaterial* MaterialObj = nullptr; + UMaterialFunction* MatFunc = nullptr; + FString AssetDisplayName; + + if (!MaterialFunction.IsEmpty()) + { + MatFunc = UMCPAssetFinder::LoadAsset(MaterialFunction, Result); + if (!MatFunc) return; + AssetDisplayName = MatFunc->GetName(); + } + else + { + MaterialObj = UMCPAssetFinder::LoadAsset(Material, Result); + if (!MaterialObj) return; + AssetDisplayName = MaterialObj->GetName(); + } + + if (MaterialObj) MCPUtils::EnsureMaterialGraph(MaterialObj); + UEdGraph* Graph = MaterialObj ? (UEdGraph*)MaterialObj->MaterialGraph : (MatFunc ? MatFunc->MaterialGraph : nullptr); + if (!Graph) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("'%s' has no material graph"), *AssetDisplayName)); + } + + // Find node by GUID + UEdGraphNode* TargetGraphNode = nullptr; + for (UEdGraphNode* GraphNode : Graph->Nodes) + { + if (!GraphNode) continue; + if (GraphNode->NodeGuid.ToString() == Node) + { + TargetGraphNode = GraphNode; + break; + } + } + + if (!TargetGraphNode) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Node '%s' not found in material graph"), *Node)); + } + + // Find pin + UEdGraphPin* Pin = TargetGraphNode->FindPin(FName(*PinName)); + if (!Pin) + { + TArray> PinNames; + for (UEdGraphPin* P : TargetGraphNode->Pins) + { + if (P) PinNames.Add(MakeShared( + FString::Printf(TEXT("%s (%s)"), *P->PinName.ToString(), + P->Direction == EGPD_Input ? TEXT("Input") : TEXT("Output")))); + } + MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Pin '%s' not found on node '%s'"), + *PinName, *Node)); + Result->SetArrayField(TEXT("availablePins"), PinNames); + return; + } + + int32 BrokenCount = Pin->LinkedTo.Num(); + + if (DryRun) + { + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: [DRY RUN] Would disconnect pin '%s' on node '%s' in '%s' (%d links)"), + *PinName, *Node, *AssetDisplayName, BrokenCount); + + Result->SetBoolField(TEXT("success"), true); + Result->SetBoolField(TEXT("dryRun"), true); + Result->SetStringField(TEXT("material"), AssetDisplayName); + Result->SetStringField(TEXT("nodeId"), Node); + Result->SetStringField(TEXT("pinName"), PinName); + Result->SetNumberField(TEXT("brokenLinkCount"), BrokenCount); + return; + } + + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Disconnecting pin '%s' on node '%s' in '%s' (%d links)"), + *PinName, *Node, *AssetDisplayName, BrokenCount); + + // Break all links + Pin->BreakAllPinLinks(); + + UObject* Asset = MaterialObj ? (UObject*)MaterialObj : (UObject*)MatFunc; + Asset->PreEditChange(nullptr); + Asset->PostEditChange(); + + // Save + bool bSaved = MaterialObj ? MCPUtils::SaveMaterialPackage(MaterialObj) : MCPUtils::SaveGenericPackage(MatFunc); + + Result->SetBoolField(TEXT("success"), true); + Result->SetStringField(TEXT("material"), AssetDisplayName); + Result->SetStringField(TEXT("nodeId"), Node); + Result->SetStringField(TEXT("pinName"), PinName); + Result->SetNumberField(TEXT("brokenLinkCount"), BrokenCount); + Result->SetBoolField(TEXT("saved"), bSaved); + } +}; + +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + +UCLASS(meta=(ToolName="set_material_expression_property")) +class UMCPHandler_SetExpressionValue : public UObject, public IMCPHandler +{ + GENERATED_BODY() + +public: + UPROPERTY(meta=(Optional, Description="Material name or package path (specify this or materialFunction)")) + FString Material; + + UPROPERTY(meta=(Optional, Description="Material function name or package path (specify this or material)")) + FString MaterialFunction; + + UPROPERTY(meta=(Description="Node GUID of the expression to modify")) + FString Node; + + virtual FString GetDescription() const override + { + return TEXT("Set the value or properties on a material expression node. " + "The 'value' field in the JSON payload provides the new value, whose format depends on the expression type."); + } + + virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override + { + if (Material.IsEmpty() && MaterialFunction.IsEmpty()) + { + return MCPUtils::MakeErrorJson(Result, TEXT("Missing required field: 'material' or 'materialFunction'")); + } + + if (!Json->HasField(TEXT("value"))) + { + return MCPUtils::MakeErrorJson(Result, TEXT("Missing required field: value")); + } + + // Load material or material function + UMaterial* MaterialObj = nullptr; + UMaterialFunction* MatFunc = nullptr; + FString AssetDisplayName; + + if (!MaterialFunction.IsEmpty()) + { + MatFunc = UMCPAssetFinder::LoadAsset(MaterialFunction, Result); + if (!MatFunc) return; + AssetDisplayName = MatFunc->GetName(); + } + else + { + MaterialObj = UMCPAssetFinder::LoadAsset(Material, Result); + if (!MaterialObj) return; + AssetDisplayName = MaterialObj->GetName(); + } + + if (MaterialObj) MCPUtils::EnsureMaterialGraph(MaterialObj); + UEdGraph* Graph = MaterialObj ? (UEdGraph*)MaterialObj->MaterialGraph : (MatFunc ? MatFunc->MaterialGraph : nullptr); + if (!Graph) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("'%s' has no material graph"), *AssetDisplayName)); + } + + // Find the node by GUID + UMaterialGraphNode* TargetMatNode = nullptr; + for (UEdGraphNode* GraphNode : Graph->Nodes) + { + if (!GraphNode) continue; + if (GraphNode->NodeGuid.ToString() == Node) + { + TargetMatNode = Cast(GraphNode); + break; + } + } + + if (!TargetMatNode) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Node '%s' not found in material graph"), *Node)); + } + + UMaterialExpression* Expr = TargetMatNode->MaterialExpression; + if (!Expr) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Node '%s' has no associated material expression"), *Node)); + } + + FString ExprType; + FString NewValueStr; + + UObject* Asset = MaterialObj ? (UObject*)MaterialObj : (UObject*)MatFunc; + Asset->PreEditChange(nullptr); + + // Handle based on expression type + if (UMaterialExpressionConstant* ConstExpr = Cast(Expr)) + { + ExprType = TEXT("Constant"); + double Value = Json->GetNumberField(TEXT("value")); + ConstExpr->R = (float)Value; + NewValueStr = FString::Printf(TEXT("%f"), Value); + } + else if (UMaterialExpressionConstant3Vector* C3Expr = Cast(Expr)) + { + ExprType = TEXT("Constant3Vector"); + const TSharedPtr* ValueObj = nullptr; + if (Json->TryGetObjectField(TEXT("value"), ValueObj) && ValueObj && (*ValueObj).IsValid()) + { + double R = 0, G = 0, B = 0; + (*ValueObj)->TryGetNumberField(TEXT("r"), R); + (*ValueObj)->TryGetNumberField(TEXT("g"), G); + (*ValueObj)->TryGetNumberField(TEXT("b"), B); + C3Expr->Constant = FLinearColor((float)R, (float)G, (float)B); + NewValueStr = FString::Printf(TEXT("(%f, %f, %f)"), R, G, B); + } + else + { + Asset->PostEditChange(); + return MCPUtils::MakeErrorJson(Result, TEXT("Constant3Vector requires value as object {r, g, b}")); + } + } + else if (UMaterialExpressionConstant4Vector* C4Expr = Cast(Expr)) + { + ExprType = TEXT("Constant4Vector"); + const TSharedPtr* ValueObj = nullptr; + if (Json->TryGetObjectField(TEXT("value"), ValueObj) && ValueObj && (*ValueObj).IsValid()) + { + double R = 0, G = 0, B = 0, A = 1; + (*ValueObj)->TryGetNumberField(TEXT("r"), R); + (*ValueObj)->TryGetNumberField(TEXT("g"), G); + (*ValueObj)->TryGetNumberField(TEXT("b"), B); + (*ValueObj)->TryGetNumberField(TEXT("a"), A); + C4Expr->Constant = FLinearColor((float)R, (float)G, (float)B, (float)A); + NewValueStr = FString::Printf(TEXT("(%f, %f, %f, %f)"), R, G, B, A); + } + else + { + Asset->PostEditChange(); + return MCPUtils::MakeErrorJson(Result, TEXT("Constant4Vector requires value as object {r, g, b, a}")); + } + } + else if (UMaterialExpressionScalarParameter* SPExpr = Cast(Expr)) + { + ExprType = TEXT("ScalarParameter"); + double Value = Json->GetNumberField(TEXT("value")); + SPExpr->DefaultValue = (float)Value; + NewValueStr = FString::Printf(TEXT("%f"), Value); + + FString ParamName; + if (Json->TryGetStringField(TEXT("parameterName"), ParamName) && !ParamName.IsEmpty()) + { + SPExpr->ParameterName = FName(*ParamName); + } + } + else if (UMaterialExpressionVectorParameter* VPExpr = Cast(Expr)) + { + ExprType = TEXT("VectorParameter"); + const TSharedPtr* ValueObj = nullptr; + if (Json->TryGetObjectField(TEXT("value"), ValueObj) && ValueObj && (*ValueObj).IsValid()) + { + double R = 0, G = 0, B = 0, A = 1; + (*ValueObj)->TryGetNumberField(TEXT("r"), R); + (*ValueObj)->TryGetNumberField(TEXT("g"), G); + (*ValueObj)->TryGetNumberField(TEXT("b"), B); + (*ValueObj)->TryGetNumberField(TEXT("a"), A); + VPExpr->DefaultValue = FLinearColor((float)R, (float)G, (float)B, (float)A); + NewValueStr = FString::Printf(TEXT("(%f, %f, %f, %f)"), R, G, B, A); + } + else + { + Asset->PostEditChange(); + return MCPUtils::MakeErrorJson(Result, TEXT("VectorParameter requires value as object {r, g, b, a}")); + } + + FString ParamName; + if (Json->TryGetStringField(TEXT("parameterName"), ParamName) && !ParamName.IsEmpty()) + { + VPExpr->ParameterName = FName(*ParamName); + } + } + else if (UMaterialExpressionTextureCoordinate* TCExpr = Cast(Expr)) + { + ExprType = TEXT("TextureCoordinate"); + const TSharedPtr* ValueObj = nullptr; + if (Json->TryGetObjectField(TEXT("value"), ValueObj) && ValueObj && (*ValueObj).IsValid()) + { + double CoordIndex = 0, UTiling = 1, VTiling = 1; + (*ValueObj)->TryGetNumberField(TEXT("coordinateIndex"), CoordIndex); + (*ValueObj)->TryGetNumberField(TEXT("uTiling"), UTiling); + (*ValueObj)->TryGetNumberField(TEXT("vTiling"), VTiling); + TCExpr->CoordinateIndex = (int32)CoordIndex; + TCExpr->UTiling = (float)UTiling; + TCExpr->VTiling = (float)VTiling; + NewValueStr = FString::Printf(TEXT("(index=%d, uTiling=%f, vTiling=%f)"), (int32)CoordIndex, UTiling, VTiling); + } + else + { + Asset->PostEditChange(); + return MCPUtils::MakeErrorJson(Result, TEXT("TextureCoordinate requires value as object {coordinateIndex, uTiling, vTiling}")); + } + } + else if (UMaterialExpressionCustom* CustomExpr = Cast(Expr)) + { + ExprType = TEXT("Custom"); + FString Code; + if (Json->TryGetStringField(TEXT("code"), Code)) + { + CustomExpr->Code = Code; + NewValueStr = FString::Printf(TEXT("Code: %d chars"), Code.Len()); + } + else if (Json->HasField(TEXT("value"))) + { + // Also accept code via value field as string + FString ValueStr = Json->GetStringField(TEXT("value")); + if (!ValueStr.IsEmpty()) + { + CustomExpr->Code = ValueStr; + NewValueStr = FString::Printf(TEXT("Code: %d chars"), ValueStr.Len()); + } + } + + FString OutputTypeStr; + if (Json->TryGetStringField(TEXT("outputType"), OutputTypeStr) && !OutputTypeStr.IsEmpty()) + { + int64 EnumVal = StaticEnum()->GetValueByNameString(OutputTypeStr); + if (EnumVal != INDEX_NONE) + { + CustomExpr->OutputType = (ECustomMaterialOutputType)EnumVal; + } + } + } + else if (UMaterialExpressionComponentMask* CMExpr = Cast(Expr)) + { + ExprType = TEXT("ComponentMask"); + const TSharedPtr* ValueObj = nullptr; + if (Json->TryGetObjectField(TEXT("value"), ValueObj) && ValueObj && (*ValueObj).IsValid()) + { + bool bR = false, bG = false, bB = false, bA = false; + (*ValueObj)->TryGetBoolField(TEXT("r"), bR); + (*ValueObj)->TryGetBoolField(TEXT("g"), bG); + (*ValueObj)->TryGetBoolField(TEXT("b"), bB); + (*ValueObj)->TryGetBoolField(TEXT("a"), bA); + CMExpr->R = bR ? 1 : 0; + CMExpr->G = bG ? 1 : 0; + CMExpr->B = bB ? 1 : 0; + CMExpr->A = bA ? 1 : 0; + NewValueStr = FString::Printf(TEXT("(R=%s, G=%s, B=%s, A=%s)"), + bR ? TEXT("true") : TEXT("false"), + bG ? TEXT("true") : TEXT("false"), + bB ? TEXT("true") : TEXT("false"), + bA ? TEXT("true") : TEXT("false")); + } + else + { + Asset->PostEditChange(); + return MCPUtils::MakeErrorJson(Result, TEXT("ComponentMask requires value as object {r, g, b, a} (booleans)")); + } + } + else + { + Asset->PostEditChange(); + return MCPUtils::MakeErrorJson(Result, FString::Printf( + TEXT("Expression type '%s' does not support direct value setting. Supported types: Constant, " + "Constant3Vector, Constant4Vector, ScalarParameter, VectorParameter, TextureCoordinate, " + "Custom, ComponentMask"), + *Expr->GetClass()->GetName())); + } + + Asset->PostEditChange(); + Asset->MarkPackageDirty(); + + // Save + bool bSaved = MaterialObj ? MCPUtils::SaveMaterialPackage(MaterialObj) : MCPUtils::SaveGenericPackage(MatFunc); + + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Set expression value on node '%s' (%s) in '%s': %s"), + *Node, *ExprType, *AssetDisplayName, *NewValueStr); + + Result->SetBoolField(TEXT("success"), true); + Result->SetStringField(TEXT("material"), AssetDisplayName); + Result->SetStringField(TEXT("nodeId"), Node); + Result->SetStringField(TEXT("expressionType"), ExprType); + Result->SetStringField(TEXT("newValue"), NewValueStr); + Result->SetBoolField(TEXT("saved"), bSaved); + } +}; + +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + +UCLASS(meta=(ToolName="set_material_expression_position")) +class UMCPHandler_MoveMaterialExpression : public UObject, public IMCPHandler +{ + GENERATED_BODY() + +public: + UPROPERTY(meta=(Optional, Description="Material name or package path (specify this or materialFunction)")) + FString Material; + + UPROPERTY(meta=(Optional, Description="Material function name or package path (specify this or material)")) + FString MaterialFunction; + + UPROPERTY(meta=(Description="Node GUID of the expression to reposition")) + FString Node; + + UPROPERTY(meta=(Description="New X position")) + int32 PosX = 0; + + UPROPERTY(meta=(Description="New Y position")) + int32 PosY = 0; + + UPROPERTY(meta=(Optional, Description="If true, preview the change without applying it")) + bool DryRun = false; + + virtual FString GetDescription() const override + { + return TEXT("Reposition a material expression node in the material graph editor."); + } + + virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override + { + if (Material.IsEmpty() && MaterialFunction.IsEmpty()) + { + return MCPUtils::MakeErrorJson(Result, TEXT("Missing required field: 'material' or 'materialFunction'")); + } + + // Load material or material function + UMaterial* MaterialObj = nullptr; + UMaterialFunction* MatFunc = nullptr; + FString AssetDisplayName; + + if (!MaterialFunction.IsEmpty()) + { + MatFunc = UMCPAssetFinder::LoadAsset(MaterialFunction, Result); + if (!MatFunc) return; + AssetDisplayName = MatFunc->GetName(); + } + else + { + MaterialObj = UMCPAssetFinder::LoadAsset(Material, Result); + if (!MaterialObj) return; + AssetDisplayName = MaterialObj->GetName(); + } + + if (MaterialObj) MCPUtils::EnsureMaterialGraph(MaterialObj); + UEdGraph* Graph = MaterialObj ? (UEdGraph*)MaterialObj->MaterialGraph : (MatFunc ? MatFunc->MaterialGraph : nullptr); + if (!Graph) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("'%s' has no material graph"), *AssetDisplayName)); + } + + // Find node by GUID + UMaterialGraphNode* TargetMatNode = nullptr; + for (UEdGraphNode* GraphNode : Graph->Nodes) + { + if (!GraphNode) continue; + if (GraphNode->NodeGuid.ToString() == Node) + { + TargetMatNode = Cast(GraphNode); + break; + } + } + + if (!TargetMatNode) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Node '%s' not found in material graph"), *Node)); + } + + if (DryRun) + { + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: [DRY RUN] Would move node '%s' to (%d, %d) in '%s'"), + *Node, PosX, PosY, *AssetDisplayName); + + Result->SetBoolField(TEXT("success"), true); + Result->SetBoolField(TEXT("dryRun"), true); + Result->SetStringField(TEXT("material"), AssetDisplayName); + Result->SetStringField(TEXT("nodeId"), Node); + Result->SetNumberField(TEXT("posX"), PosX); + Result->SetNumberField(TEXT("posY"), PosY); + return; + } + + // Set position on the graph node + TargetMatNode->NodePosX = PosX; + TargetMatNode->NodePosY = PosY; + + // Also update the underlying expression position + if (TargetMatNode->MaterialExpression) + { + TargetMatNode->MaterialExpression->MaterialExpressionEditorX = PosX; + TargetMatNode->MaterialExpression->MaterialExpressionEditorY = PosY; + } + + UObject* Asset = MaterialObj ? (UObject*)MaterialObj : (UObject*)MatFunc; + Asset->PreEditChange(nullptr); + Asset->PostEditChange(); + + // Save + bool bSaved = MaterialObj ? MCPUtils::SaveMaterialPackage(MaterialObj) : MCPUtils::SaveGenericPackage(MatFunc); + + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Moved node '%s' to (%d, %d) in '%s' (saved: %s)"), + *Node, PosX, PosY, *AssetDisplayName, bSaved ? TEXT("true") : TEXT("false")); + + Result->SetBoolField(TEXT("success"), true); + Result->SetStringField(TEXT("material"), AssetDisplayName); + Result->SetStringField(TEXT("nodeId"), Node); + Result->SetNumberField(TEXT("posX"), PosX); + Result->SetNumberField(TEXT("posY"), PosY); + Result->SetBoolField(TEXT("saved"), bSaved); + } +}; + +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + +UCLASS(meta=(ToolName="create_material_function_asset")) +class UMCPHandler_CreateMaterialFunction : public UObject, public IMCPHandler +{ + GENERATED_BODY() + +public: + UPROPERTY(meta=(Description="Name for the new material function asset")) + FString Name; + + UPROPERTY(meta=(Description="Package path where the asset will be created (must start with /Game)")) + FString PackagePath; + + UPROPERTY(meta=(Optional, Description="Description for the material function")) + FString Description; + + virtual FString GetDescription() const override + { + return TEXT("Create a new UMaterialFunction asset with an optional description."); + } + + virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override + { + if (!PackagePath.StartsWith(TEXT("/Game"))) + { + return MCPUtils::MakeErrorJson(Result, TEXT("packagePath must start with '/Game'")); + } + + // Check if asset already exists + FString FullAssetPath = PackagePath / Name; + if (UMCPAssetFinder::FindAsset(UMaterialFunction::StaticClass(), Name) || UMCPAssetFinder::FindAsset(UMaterialFunction::StaticClass(), FullAssetPath)) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf( + TEXT("Material Function '%s' already exists. Use a different name or delete the existing asset first."), + *Name)); + } + + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Creating Material Function '%s' in '%s'"), *Name, *PackagePath); + + // Create via IAssetTools + factory + IAssetTools& AssetTools = FModuleManager::LoadModuleChecked("AssetTools").Get(); + UMaterialFunctionFactoryNew* Factory = NewObject(); + UObject* NewAsset = AssetTools.CreateAsset(Name, PackagePath, UMaterialFunction::StaticClass(), Factory); + + if (!NewAsset) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Failed to create Material Function '%s' in '%s'"), *Name, *PackagePath)); + } + + UMaterialFunction* MF = Cast(NewAsset); + if (!MF) + { + return MCPUtils::MakeErrorJson(Result, TEXT("Created asset is not a UMaterialFunction")); + } + + // Set optional description + if (!Description.IsEmpty()) + { + MF->Description = Description; + } + + // Save + bool bSaved = MCPUtils::SaveGenericPackage(MF); + + + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Created Material Function '%s' (saved: %s)"), + *Name, bSaved ? TEXT("true") : TEXT("false")); + + Result->SetBoolField(TEXT("success"), true); + Result->SetStringField(TEXT("name"), Name); + Result->SetStringField(TEXT("path"), MF->GetPathName()); + if (!Description.IsEmpty()) + { + Result->SetStringField(TEXT("description"), Description); + } + Result->SetBoolField(TEXT("saved"), bSaved); + } +}; diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_MaterialRead.cpp b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_MaterialRead.cpp deleted file mode 100644 index 36529783..00000000 --- a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_MaterialRead.cpp +++ /dev/null @@ -1,927 +0,0 @@ -#include "MCPAssetFinder.h" -#include "MCPServer.h" -#include "MCPUtils.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 -// ============================================================ - -void FBlueprintMCPServer::HandleListMaterials(const FJsonObject* Json, FJsonObject* Result) -{ - FString Filter = Json->GetStringField(TEXT("filter")); - FString TypeFilter = Json->GetStringField(TEXT("type")); - - bool bIncludeMaterials = TypeFilter.IsEmpty() || TypeFilter == TEXT("all") || TypeFilter == TEXT("material"); - bool bIncludeInstances = TypeFilter.IsEmpty() || TypeFilter == TEXT("all") || TypeFilter == TEXT("instance"); - - TArray> Entries; - - if (bIncludeMaterials) - { - for (const FAssetData& Asset : UMCPAssetFinder::GetAssets(UMaterial::StaticClass())) - { - FString Name = Asset.AssetName.ToString(); - FString Path = Asset.PackageName.ToString(); - - if (!Filter.IsEmpty()) - { - if (!Name.Contains(Filter, ESearchCase::IgnoreCase) && - !Path.Contains(Filter, ESearchCase::IgnoreCase)) - { - continue; - } - } - - TSharedRef Entry = MakeShared(); - Entry->SetStringField(TEXT("name"), Name); - Entry->SetStringField(TEXT("path"), Path); - Entry->SetStringField(TEXT("type"), TEXT("Material")); - Entries.Add(MakeShared(Entry)); - } - } - - if (bIncludeInstances) - { - for (const FAssetData& Asset : UMCPAssetFinder::GetAssets(UMaterialInstanceConstant::StaticClass())) - { - FString Name = Asset.AssetName.ToString(); - FString Path = Asset.PackageName.ToString(); - - if (!Filter.IsEmpty()) - { - if (!Name.Contains(Filter, ESearchCase::IgnoreCase) && - !Path.Contains(Filter, ESearchCase::IgnoreCase)) - { - continue; - } - } - - TSharedRef Entry = MakeShared(); - Entry->SetStringField(TEXT("name"), Name); - Entry->SetStringField(TEXT("path"), Path); - Entry->SetStringField(TEXT("type"), TEXT("MaterialInstance")); - Entries.Add(MakeShared(Entry)); - } - } - - int32 Total = UMCPAssetFinder::GetAssets(UMaterial::StaticClass()).Num() + UMCPAssetFinder::GetAssets(UMaterialInstanceConstant::StaticClass()).Num(); - - Result->SetNumberField(TEXT("count"), Entries.Num()); - Result->SetNumberField(TEXT("total"), Total); - Result->SetArrayField(TEXT("materials"), Entries); -} - -// ============================================================ -// HandleGetMaterial — detailed info about a material or instance -// ============================================================ - -void FBlueprintMCPServer::HandleGetMaterial(const FJsonObject* Json, FJsonObject* Result) -{ - FString Name = Json->GetStringField(TEXT("name")); - if (Name.IsEmpty()) - { - return MCPUtils::MakeErrorJson(Result, TEXT("Missing 'name' parameter")); - } - - FString DecodedName = MCPUtils::UrlDecode(Name); - - // Try loading as UMaterial first - FString LoadError; - UMaterial* Material = UMCPAssetFinder::LoadAsset(DecodedName, LoadError); - if (Material) - { - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: GetMaterial — loaded material '%s'"), *Material->GetName()); - - 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()) - { - DomainStr = DomainEnum->GetNameStringByValue((int64)Material->MaterialDomain); - } - Result->SetStringField(TEXT("domain"), DomainStr); - - // Blend mode - FString BlendModeStr = TEXT("Unknown"); - if (const UEnum* BlendEnum = StaticEnum()) - { - BlendModeStr = BlendEnum->GetNameStringByValue((int64)Material->BlendMode); - } - Result->SetStringField(TEXT("blendMode"), BlendModeStr); - - // Shading models - TArray> ShadingModels; - FMaterialShadingModelField SMField = Material->GetShadingModels(); - if (const UEnum* SMEnum = StaticEnum()) - { - for (int32 i = 0; i < SMEnum->NumEnums() - 1; ++i) - { - EMaterialShadingModel SM = (EMaterialShadingModel)SMEnum->GetValueByIndex(i); - if (SMField.HasShadingModel(SM)) - { - ShadingModels.Add(MakeShared(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> Parameters; - for (UMaterialExpression* Expr : Expressions) - { - if (!Expr) continue; - - TSharedRef ParamObj = MakeShared(); - bool bIsParam = false; - - if (auto* SP = Cast(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(Expr)) - { - bIsParam = true; - ParamObj->SetStringField(TEXT("name"), VP->ParameterName.ToString()); - ParamObj->SetStringField(TEXT("type"), TEXT("Vector")); - ParamObj->SetStringField(TEXT("group"), VP->Group.ToString()); - TSharedRef DefVal = MakeShared(); - 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(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(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(ParamObj)); - } - } - Result->SetArrayField(TEXT("parameters"), Parameters); - - // Referenced textures - TArray> ReferencedTextures; - auto RefTexObjs = Material->GetReferencedTextures(); - for (const TObjectPtr& TexObj : RefTexObjs) - { - if (TexObj) - { - ReferencedTextures.Add(MakeShared(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 UsageFlags = MakeShared(); - 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()) - { - TextureSampleCount++; - } - } - Result->SetNumberField(TEXT("textureSampleCount"), TextureSampleCount); - - return; - } - - // Try loading as MaterialInstance - FString MILoadError; - UMaterialInstanceConstant* MI = UMCPAssetFinder::LoadAsset(DecodedName, MILoadError); - if (MI) - { - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: GetMaterial — loaded material instance '%s'"), *MI->GetName()); - - 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> OverriddenParams; - - // Scalar parameters - for (const FScalarParameterValue& Param : MI->ScalarParameterValues) - { - TSharedRef PObj = MakeShared(); - PObj->SetStringField(TEXT("name"), Param.ParameterInfo.Name.ToString()); - PObj->SetStringField(TEXT("type"), TEXT("Scalar")); - PObj->SetNumberField(TEXT("value"), Param.ParameterValue); - OverriddenParams.Add(MakeShared(PObj)); - } - - // Vector parameters - for (const FVectorParameterValue& Param : MI->VectorParameterValues) - { - TSharedRef PObj = MakeShared(); - PObj->SetStringField(TEXT("name"), Param.ParameterInfo.Name.ToString()); - PObj->SetStringField(TEXT("type"), TEXT("Vector")); - TSharedRef Val = MakeShared(); - 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(PObj)); - } - - // Texture parameters - for (const FTextureParameterValue& Param : MI->TextureParameterValues) - { - TSharedRef PObj = MakeShared(); - 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(PObj)); - } - - // Static switch parameters - for (const FStaticSwitchParameter& Param : MI->GetStaticParameters().StaticSwitchParameters) - { - TSharedRef PObj = MakeShared(); - 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(PObj)); - } - - Result->SetArrayField(TEXT("overriddenParameters"), OverriddenParams); - - return; - } - - MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Material or MaterialInstance '%s' not found. Use list_materials to see available assets."), *DecodedName)); -} - -// ============================================================ -// HandleGetMaterialGraph — serialized graph for a material -// ============================================================ - -void FBlueprintMCPServer::HandleGetMaterialGraph(const FJsonObject* Json, FJsonObject* Result) -{ - FString Name = Json->GetStringField(TEXT("name")); - if (Name.IsEmpty()) - { - return MCPUtils::MakeErrorJson(Result, TEXT("Missing 'name' parameter")); - } - - FString DecodedName = MCPUtils::UrlDecode(Name); - - UMaterial* Material = UMCPAssetFinder::LoadAsset(DecodedName, Result); - if (!Material) return; - - 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( - FBlueprintEditorUtils::CreateNewGraph(Material, NAME_None, UMaterialGraph::StaticClass(), UMaterialGraphSchema::StaticClass())); - Material->MaterialGraph->Material = Material; - Material->MaterialGraph->RebuildGraph(); - } - - if (!Material->MaterialGraph) - { - return MCPUtils::MakeErrorJson(Result, TEXT("Could not build MaterialGraph for this material")); - } - - TSharedPtr GraphJson = MCPUtils::SerializeGraph(Material->MaterialGraph); - if (!GraphJson.IsValid()) - { - return MCPUtils::MakeErrorJson(Result, TEXT("Failed to serialize material graph")); - } - - MCPUtils::CopyJsonFields(GraphJson.Get(), Result); - - // Add material name context - Result->SetStringField(TEXT("material"), Material->GetName()); - Result->SetStringField(TEXT("materialPath"), Material->GetPathName()); -} - -// ============================================================ -// HandleDescribeMaterial — human-readable material description -// ============================================================ - -void FBlueprintMCPServer::HandleDescribeMaterial(const FJsonObject* Json, FJsonObject* Result) -{ - FString MaterialName = Json->GetStringField(TEXT("material")); - if (MaterialName.IsEmpty()) - { - return MCPUtils::MakeErrorJson(Result, TEXT("Missing required field: material")); - } - - UMaterial* Material = UMCPAssetFinder::LoadAsset(MaterialName, Result); - if (!Material) return; - - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: DescribeMaterial — '%s'"), *Material->GetName()); - - // Ensure material graph is built - if (!Material->MaterialGraph) - { - Material->MaterialGraph = CastChecked( - FBlueprintEditorUtils::CreateNewGraph(Material, NAME_None, UMaterialGraph::StaticClass(), UMaterialGraphSchema::StaticClass())); - Material->MaterialGraph->Material = Material; - Material->MaterialGraph->RebuildGraph(); - } - - if (!Material->MaterialGraph) - { - return MCPUtils::MakeErrorJson(Result, TEXT("Could not build MaterialGraph for this material")); - } - - // Recursive helper: trace backwards from a pin and build a description string - TFunction 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 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(SourceNode)) - { - UMaterialExpression* Expr = MatNode->MaterialExpression; - if (!Expr) - { - NodeDesc = TEXT("(null expression)"); - } - else if (auto* SP = Cast(Expr)) - { - NodeDesc = FString::Printf(TEXT("ScalarParam \"%s\" (default: %.4f)"), *SP->ParameterName.ToString(), SP->DefaultValue); - } - else if (auto* VP = Cast(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(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(Expr)) - { - NodeDesc = FString::Printf(TEXT("StaticSwitchParam \"%s\" (default: %s)"), - *SSP->ParameterName.ToString(), SSP->DefaultValue ? TEXT("true") : TEXT("false")); - } - else if (auto* SC = Cast(Expr)) - { - NodeDesc = FString::Printf(TEXT("Constant(%.4f)"), SC->R); - } - else if (auto* C3 = Cast(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(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(Expr)) - { - FString TexName = TS->Texture ? TS->Texture->GetName() : TEXT("None"); - NodeDesc = FString::Printf(TEXT("TextureSample(%s)"), *TexName); - } - else if (auto* MFC = Cast(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 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> InputDescriptions; - - UMaterialGraphNode_Root* RootNode = nullptr; - for (UEdGraphNode* Node : Material->MaterialGraph->Nodes) - { - RootNode = Cast(Node); - if (RootNode) break; - } - - if (!RootNode) - { - return MCPUtils::MakeErrorJson(Result, 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 InputObj = MakeShared(); - InputObj->SetStringField(TEXT("input"), PinName); - InputObj->SetStringField(TEXT("chain"), Description); - InputObj->SetBoolField(TEXT("connected"), Pin->LinkedTo.Num() > 0); - InputDescriptions.Add(MakeShared(InputObj)); - } - - 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& Val : InputDescriptions) - { - TSharedPtr 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); - } -} - -// ============================================================ -// HandleSearchMaterials — search expressions and parameters -// ============================================================ - -void FBlueprintMCPServer::HandleSearchMaterials(const FJsonObject* Json, FJsonObject* Result) -{ - FString Query = Json->GetStringField(TEXT("query")); - if (Query.IsEmpty()) - { - return MCPUtils::MakeErrorJson(Result, TEXT("Missing 'query' parameter")); - } - - FString DecodedQuery = MCPUtils::UrlDecode(Query); - - int32 MaxResults = 50; - if (Json->HasField(TEXT("maxResults"))) - { - MaxResults = FMath::Clamp((int32)Json->GetNumberField(TEXT("maxResults")), 1, 200); - } - - TArray> Results; - - for (const FAssetData& Asset : UMCPAssetFinder::GetAssets(UMaterial::StaticClass())) - { - if (Results.Num() >= MaxResults) break; - - FString MatName = Asset.AssetName.ToString(); - - // Check material name first - bool bNameMatch = MatName.Contains(DecodedQuery, ESearchCase::IgnoreCase); - - UMaterial* Material = Cast(const_cast(Asset).GetAsset()); - if (!Material) continue; - - auto Expressions = Material->GetExpressions(); - - if (bNameMatch) - { - // Add a match for the material itself - TSharedRef R = MakeShared(); - R->SetStringField(TEXT("material"), MatName); - R->SetStringField(TEXT("materialPath"), Asset.PackageName.ToString()); - R->SetStringField(TEXT("matchType"), TEXT("materialName")); - Results.Add(MakeShared(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(Expr)) - ParamName = SP->ParameterName.ToString(); - else if (auto* VP = Cast(Expr)) - ParamName = VP->ParameterName.ToString(); - else if (auto* TP = Cast(Expr)) - ParamName = TP->ParameterName.ToString(); - else if (auto* SSP = Cast(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 R = MakeShared(); - 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(R)); - } - } - } - - Result->SetStringField(TEXT("query"), DecodedQuery); - Result->SetNumberField(TEXT("resultCount"), Results.Num()); - Result->SetArrayField(TEXT("results"), Results); -} - -// ============================================================ -// HandleFindMaterialReferences — find assets referencing a material -// ============================================================ - -void FBlueprintMCPServer::HandleFindMaterialReferences(const FJsonObject* Json, FJsonObject* Result) -{ - FString MaterialName = Json->GetStringField(TEXT("material")); - if (MaterialName.IsEmpty()) - { - return MCPUtils::MakeErrorJson(Result, TEXT("Missing required field: material")); - } - - // Try to find the material's package path - FString PackagePath; - FAssetData* MatAsset = UMCPAssetFinder::FindAsset(UMaterial::StaticClass(), MaterialName); - if (MatAsset) - { - PackagePath = MatAsset->PackageName.ToString(); - } - else - { - // Try material instance - FAssetData* MIAsset = UMCPAssetFinder::FindAsset(UMaterialInstanceConstant::StaticClass(), MaterialName); - if (MIAsset) - { - PackagePath = MIAsset->PackageName.ToString(); - } - } - - if (PackagePath.IsEmpty()) - { - return MCPUtils::MakeErrorJson(Result, 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 Referencers; - Registry.GetReferencers(FName(*PackagePath), Referencers); - - TArray> RefArray; - for (const FName& Ref : Referencers) - { - FString RefStr = Ref.ToString(); - // Skip self-reference - if (RefStr == PackagePath) continue; - RefArray.Add(MakeShared(RefStr)); - } - - 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); -} - -// ============================================================ -// HandleListMaterialFunctions — list MaterialFunction assets -// ============================================================ - -void FBlueprintMCPServer::HandleListMaterialFunctions(const FJsonObject* Json, FJsonObject* Result) -{ - FString Filter = Json->GetStringField(TEXT("filter")); - - TArray> Entries; - - for (const FAssetData& Asset : UMCPAssetFinder::GetAssets(UMaterialFunction::StaticClass())) - { - FString Name = Asset.AssetName.ToString(); - FString Path = Asset.PackageName.ToString(); - - if (!Filter.IsEmpty()) - { - if (!Name.Contains(Filter, ESearchCase::IgnoreCase) && - !Path.Contains(Filter, ESearchCase::IgnoreCase)) - { - continue; - } - } - - TSharedRef Entry = MakeShared(); - Entry->SetStringField(TEXT("name"), Name); - Entry->SetStringField(TEXT("path"), Path); - Entries.Add(MakeShared(Entry)); - } - - Result->SetNumberField(TEXT("count"), Entries.Num()); - Result->SetNumberField(TEXT("total"), UMCPAssetFinder::GetAssets(UMaterialFunction::StaticClass()).Num()); - Result->SetArrayField(TEXT("functions"), Entries); -} - -// ============================================================ -// HandleGetMaterialFunction — detailed info about a material function -// ============================================================ - -void FBlueprintMCPServer::HandleGetMaterialFunction(const FJsonObject* Json, FJsonObject* Result) -{ - FString Name = Json->GetStringField(TEXT("name")); - if (Name.IsEmpty()) - { - return MCPUtils::MakeErrorJson(Result, TEXT("Missing 'name' parameter")); - } - - FString DecodedName = MCPUtils::UrlDecode(Name); - - UMaterialFunction* MF = UMCPAssetFinder::LoadAsset(DecodedName, Result); - if (!MF) return; - - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: GetMaterialFunction — '%s'"), *MF->GetName()); - - 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> Inputs; - TArray> Outputs; - TArray> ExpressionList; - - { - for (UMaterialExpression* Expr : Expressions) - { - if (!Expr) continue; - - if (auto* FI = Cast(Expr)) - { - TSharedRef InputObj = MakeShared(); - 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(InputObj)); - } - else if (auto* FO = Cast(Expr)) - { - TSharedRef OutputObj = MakeShared(); - 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(OutputObj)); - } - - // Serialize every expression - TSharedPtr ExprJson = MCPUtils::SerializeMaterialExpression(Expr); - if (ExprJson.IsValid()) - { - ExpressionList.Add(MakeShared(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 GraphJson = MCPUtils::SerializeGraph(FuncGraph); - if (GraphJson.IsValid()) - { - Result->SetObjectField(TEXT("graph"), GraphJson); - } - } -} - -// ============================================================ -// HandleValidateMaterial — force recompile and check for errors -// ============================================================ - -void FBlueprintMCPServer::HandleValidateMaterial(const FJsonObject* Json, FJsonObject* Result) -{ - FString MaterialName = Json->GetStringField(TEXT("material")); - if (MaterialName.IsEmpty()) - { - return MCPUtils::MakeErrorJson(Result, TEXT("Missing required field: material")); - } - - // Load material - UMaterial* Material = UMCPAssetFinder::LoadAsset(MaterialName, Result); - if (!Material) return; - - 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> ErrorArray; - bool bValid = true; - - // Check for compilation errors via FMaterialResource on current platform - FMaterialResource* Resource = Material->GetMaterialResource(GMaxRHIFeatureLevel); - if (Resource) - { - const TArray& CompileErrors = Resource->GetCompileErrors(); - for (const FString& Err : CompileErrors) - { - bValid = false; - ErrorArray.Add(MakeShared(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(); - } - } - } - } - - 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()); -} diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_MaterialRead.h b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_MaterialRead.h new file mode 100644 index 00000000..9a2613ae --- /dev/null +++ b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_MaterialRead.h @@ -0,0 +1,1020 @@ +#pragma once + +#include "CoreMinimal.h" +#include "MCPHandler.h" +#include "MCPAssetFinder.h" +#include "MCPUtils.h" +#include "Materials/Material.h" +#include "MaterialDomain.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 "EdGraph/EdGraph.h" +#include "EdGraph/EdGraphNode.h" +#include "MCPHandlers_MaterialRead.generated.h" + +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + +UCLASS(meta=(ToolName="list_material_assets")) +class UMCPHandler_ListMaterials : public UObject, public IMCPHandler +{ + GENERATED_BODY() + +public: + UPROPERTY(meta=(Optional, Description="Filter string to match against material name or path")) + FString Filter; + + UPROPERTY(meta=(Optional, Description="Type filter: 'all', 'material', or 'instance'")) + FString Type; + + virtual FString GetDescription() const override + { + return TEXT("List Material and MaterialInstance assets, optionally filtered by name and type."); + } + + virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override + { + bool bIncludeMaterials = Type.IsEmpty() || Type == TEXT("all") || Type == TEXT("material"); + bool bIncludeInstances = Type.IsEmpty() || Type == TEXT("all") || Type == TEXT("instance"); + + TArray> Entries; + + if (bIncludeMaterials) + { + for (const FAssetData& Asset : UMCPAssetFinder::GetAssets(UMaterial::StaticClass())) + { + FString Name = Asset.AssetName.ToString(); + FString Path = Asset.PackageName.ToString(); + + if (!Filter.IsEmpty()) + { + if (!Name.Contains(Filter, ESearchCase::IgnoreCase) && + !Path.Contains(Filter, ESearchCase::IgnoreCase)) + { + continue; + } + } + + TSharedRef Entry = MakeShared(); + Entry->SetStringField(TEXT("name"), Name); + Entry->SetStringField(TEXT("path"), Path); + Entry->SetStringField(TEXT("type"), TEXT("Material")); + Entries.Add(MakeShared(Entry)); + } + } + + if (bIncludeInstances) + { + for (const FAssetData& Asset : UMCPAssetFinder::GetAssets(UMaterialInstanceConstant::StaticClass())) + { + FString Name = Asset.AssetName.ToString(); + FString Path = Asset.PackageName.ToString(); + + if (!Filter.IsEmpty()) + { + if (!Name.Contains(Filter, ESearchCase::IgnoreCase) && + !Path.Contains(Filter, ESearchCase::IgnoreCase)) + { + continue; + } + } + + TSharedRef Entry = MakeShared(); + Entry->SetStringField(TEXT("name"), Name); + Entry->SetStringField(TEXT("path"), Path); + Entry->SetStringField(TEXT("type"), TEXT("MaterialInstance")); + Entries.Add(MakeShared(Entry)); + } + } + + int32 Total = UMCPAssetFinder::GetAssets(UMaterial::StaticClass()).Num() + UMCPAssetFinder::GetAssets(UMaterialInstanceConstant::StaticClass()).Num(); + + Result->SetNumberField(TEXT("count"), Entries.Num()); + Result->SetNumberField(TEXT("total"), Total); + Result->SetArrayField(TEXT("materials"), Entries); + } +}; + +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + +UCLASS(meta=(ToolName="dump_material")) +class UMCPHandler_GetMaterial : public UObject, public IMCPHandler +{ + GENERATED_BODY() + +public: + UPROPERTY(meta=(Description="Material or MaterialInstance name or package path")) + FString Material; + + virtual FString GetDescription() const override + { + return TEXT("Get detailed info about a material or material instance, including parameters, usage flags, and referenced textures."); + } + + virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override + { + FString DecodedName = MCPUtils::UrlDecode(Material); + + // Try loading as UMaterial first + FString LoadError; + UMaterial* MaterialObj = UMCPAssetFinder::LoadAsset(DecodedName, LoadError); + if (MaterialObj) + { + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: GetMaterial — loaded material '%s'"), *MaterialObj->GetName()); + + Result->SetStringField(TEXT("name"), MaterialObj->GetName()); + Result->SetStringField(TEXT("path"), MaterialObj->GetPathName()); + Result->SetStringField(TEXT("type"), TEXT("Material")); + + // Material domain + FString DomainStr = TEXT("Unknown"); + if (const UEnum* DomainEnum = StaticEnum()) + { + DomainStr = DomainEnum->GetNameStringByValue((int64)MaterialObj->MaterialDomain); + } + Result->SetStringField(TEXT("domain"), DomainStr); + + // Blend mode + FString BlendModeStr = TEXT("Unknown"); + if (const UEnum* BlendEnum = StaticEnum()) + { + BlendModeStr = BlendEnum->GetNameStringByValue((int64)MaterialObj->BlendMode); + } + Result->SetStringField(TEXT("blendMode"), BlendModeStr); + + // Shading models + TArray> ShadingModels; + FMaterialShadingModelField SMField = MaterialObj->GetShadingModels(); + if (const UEnum* SMEnum = StaticEnum()) + { + for (int32 i = 0; i < SMEnum->NumEnums() - 1; ++i) + { + EMaterialShadingModel SM = (EMaterialShadingModel)SMEnum->GetValueByIndex(i); + if (SMField.HasShadingModel(SM)) + { + ShadingModels.Add(MakeShared(SMEnum->GetNameStringByIndex(i))); + } + } + } + Result->SetArrayField(TEXT("shadingModels"), ShadingModels); + + // Two-sided + Result->SetBoolField(TEXT("twoSided"), MaterialObj->IsTwoSided()); + + // Expression count + auto Expressions = MaterialObj->GetExpressions(); + Result->SetNumberField(TEXT("expressionCount"), Expressions.Num()); + + // Parameters — iterate expressions for parameter types + TArray> Parameters; + for (UMaterialExpression* Expr : Expressions) + { + if (!Expr) continue; + + TSharedRef ParamObj = MakeShared(); + bool bIsParam = false; + + if (auto* SP = Cast(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(Expr)) + { + bIsParam = true; + ParamObj->SetStringField(TEXT("name"), VP->ParameterName.ToString()); + ParamObj->SetStringField(TEXT("type"), TEXT("Vector")); + ParamObj->SetStringField(TEXT("group"), VP->Group.ToString()); + TSharedRef DefVal = MakeShared(); + 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(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(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(ParamObj)); + } + } + Result->SetArrayField(TEXT("parameters"), Parameters); + + // Referenced textures + TArray> ReferencedTextures; + auto RefTexObjs = MaterialObj->GetReferencedTextures(); + for (const TObjectPtr& TexObj : RefTexObjs) + { + if (TexObj) + { + ReferencedTextures.Add(MakeShared(TexObj->GetPathName())); + } + } + Result->SetArrayField(TEXT("referencedTextures"), ReferencedTextures); + + // Graph node count + int32 GraphNodeCount = 0; + if (MaterialObj->MaterialGraph) + { + GraphNodeCount = MaterialObj->MaterialGraph->Nodes.Num(); + } + Result->SetNumberField(TEXT("graphNodeCount"), GraphNodeCount); + + // Usage flags + TSharedRef UsageFlags = MakeShared(); + UsageFlags->SetBoolField(TEXT("bUsedWithSkeletalMesh"), MaterialObj->bUsedWithSkeletalMesh != 0); + UsageFlags->SetBoolField(TEXT("bUsedWithMorphTargets"), MaterialObj->bUsedWithMorphTargets != 0); + UsageFlags->SetBoolField(TEXT("bUsedWithNiagaraSprites"), MaterialObj->bUsedWithNiagaraSprites != 0); + UsageFlags->SetBoolField(TEXT("bUsedWithParticleSprites"), MaterialObj->bUsedWithParticleSprites != 0); + UsageFlags->SetBoolField(TEXT("bUsedWithStaticLighting"), MaterialObj->bUsedWithStaticLighting != 0); + Result->SetObjectField(TEXT("usageFlags"), UsageFlags); + + // Opacity mask clip value + Result->SetNumberField(TEXT("opacityMaskClipValue"), MaterialObj->OpacityMaskClipValue); + + // Additional settings + Result->SetBoolField(TEXT("ditheredLODTransition"), MaterialObj->DitheredLODTransition != 0); + Result->SetBoolField(TEXT("bAllowNegativeEmissiveColor"), MaterialObj->bAllowNegativeEmissiveColor != 0); + + // Texture sample count (simple expression scan) + int32 TextureSampleCount = 0; + for (UMaterialExpression* Expr : Expressions) + { + if (Expr && Expr->IsA()) + { + TextureSampleCount++; + } + } + Result->SetNumberField(TEXT("textureSampleCount"), TextureSampleCount); + + return; + } + + // Try loading as MaterialInstance + FString MILoadError; + UMaterialInstanceConstant* MI = UMCPAssetFinder::LoadAsset(DecodedName, MILoadError); + if (MI) + { + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: GetMaterial — loaded material instance '%s'"), *MI->GetName()); + + 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> OverriddenParams; + + // Scalar parameters + for (const FScalarParameterValue& Param : MI->ScalarParameterValues) + { + TSharedRef PObj = MakeShared(); + PObj->SetStringField(TEXT("name"), Param.ParameterInfo.Name.ToString()); + PObj->SetStringField(TEXT("type"), TEXT("Scalar")); + PObj->SetNumberField(TEXT("value"), Param.ParameterValue); + OverriddenParams.Add(MakeShared(PObj)); + } + + // Vector parameters + for (const FVectorParameterValue& Param : MI->VectorParameterValues) + { + TSharedRef PObj = MakeShared(); + PObj->SetStringField(TEXT("name"), Param.ParameterInfo.Name.ToString()); + PObj->SetStringField(TEXT("type"), TEXT("Vector")); + TSharedRef Val = MakeShared(); + 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(PObj)); + } + + // Texture parameters + for (const FTextureParameterValue& Param : MI->TextureParameterValues) + { + TSharedRef PObj = MakeShared(); + 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(PObj)); + } + + // Static switch parameters + for (const FStaticSwitchParameter& Param : MI->GetStaticParameters().StaticSwitchParameters) + { + TSharedRef PObj = MakeShared(); + 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(PObj)); + } + + Result->SetArrayField(TEXT("overriddenParameters"), OverriddenParams); + + return; + } + + MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Material or MaterialInstance '%s' not found. Use list_materials to see available assets."), *DecodedName)); + } +}; + +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + +UCLASS(meta=(ToolName="dump_material_expression_graph")) +class UMCPHandler_GetMaterialGraph : public UObject, public IMCPHandler +{ + GENERATED_BODY() + +public: + UPROPERTY(meta=(Description="Material name or package path")) + FString Material; + + virtual FString GetDescription() const override + { + return TEXT("Get the serialized expression graph for a material, including all nodes and connections."); + } + + virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override + { + FString DecodedName = MCPUtils::UrlDecode(Material); + + UMaterial* MaterialObj = UMCPAssetFinder::LoadAsset(DecodedName, Result); + if (!MaterialObj) return; + + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: GetMaterialGraph — material '%s'"), *MaterialObj->GetName()); + + // Ensure the material graph is built + if (!MaterialObj->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 + MaterialObj->MaterialGraph = CastChecked( + FBlueprintEditorUtils::CreateNewGraph(MaterialObj, NAME_None, UMaterialGraph::StaticClass(), UMaterialGraphSchema::StaticClass())); + MaterialObj->MaterialGraph->Material = MaterialObj; + MaterialObj->MaterialGraph->RebuildGraph(); + } + + if (!MaterialObj->MaterialGraph) + { + return MCPUtils::MakeErrorJson(Result, TEXT("Could not build MaterialGraph for this material")); + } + + TSharedPtr GraphJson = MCPUtils::SerializeGraph(MaterialObj->MaterialGraph); + if (!GraphJson.IsValid()) + { + return MCPUtils::MakeErrorJson(Result, TEXT("Failed to serialize material graph")); + } + + MCPUtils::CopyJsonFields(GraphJson.Get(), Result); + + // Add material name context + Result->SetStringField(TEXT("material"), MaterialObj->GetName()); + Result->SetStringField(TEXT("materialPath"), MaterialObj->GetPathName()); + } +}; + +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + +UCLASS(meta=(ToolName="describe_material_in_english")) +class UMCPHandler_DescribeMaterial : public UObject, public IMCPHandler +{ + GENERATED_BODY() + +public: + UPROPERTY(meta=(Description="Material name or package path")) + FString Material; + + virtual FString GetDescription() const override + { + return TEXT("Generate a human-readable description of a material by tracing its expression graph from the root node inputs."); + } + + virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override + { + UMaterial* MaterialObj = UMCPAssetFinder::LoadAsset(Material, Result); + if (!MaterialObj) return; + + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: DescribeMaterial — '%s'"), *MaterialObj->GetName()); + + // Ensure material graph is built + if (!MaterialObj->MaterialGraph) + { + MaterialObj->MaterialGraph = CastChecked( + FBlueprintEditorUtils::CreateNewGraph(MaterialObj, NAME_None, UMaterialGraph::StaticClass(), UMaterialGraphSchema::StaticClass())); + MaterialObj->MaterialGraph->Material = MaterialObj; + MaterialObj->MaterialGraph->RebuildGraph(); + } + + if (!MaterialObj->MaterialGraph) + { + return MCPUtils::MakeErrorJson(Result, TEXT("Could not build MaterialGraph for this material")); + } + + // Recursive helper: trace backwards from a pin and build a description string + TFunction 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 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(SourceNode)) + { + UMaterialExpression* Expr = MatNode->MaterialExpression; + if (!Expr) + { + NodeDesc = TEXT("(null expression)"); + } + else if (auto* SP = Cast(Expr)) + { + NodeDesc = FString::Printf(TEXT("ScalarParam \"%s\" (default: %.4f)"), *SP->ParameterName.ToString(), SP->DefaultValue); + } + else if (auto* VP = Cast(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(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(Expr)) + { + NodeDesc = FString::Printf(TEXT("StaticSwitchParam \"%s\" (default: %s)"), + *SSP->ParameterName.ToString(), SSP->DefaultValue ? TEXT("true") : TEXT("false")); + } + else if (auto* SC = Cast(Expr)) + { + NodeDesc = FString::Printf(TEXT("Constant(%.4f)"), SC->R); + } + else if (auto* C3 = Cast(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(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(Expr)) + { + FString TexName = TS->Texture ? TS->Texture->GetName() : TEXT("None"); + NodeDesc = FString::Printf(TEXT("TextureSample(%s)"), *TexName); + } + else if (auto* MFC = Cast(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 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> InputDescriptions; + + UMaterialGraphNode_Root* RootNode = nullptr; + for (UEdGraphNode* Node : MaterialObj->MaterialGraph->Nodes) + { + RootNode = Cast(Node); + if (RootNode) break; + } + + if (!RootNode) + { + return MCPUtils::MakeErrorJson(Result, 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 InputObj = MakeShared(); + InputObj->SetStringField(TEXT("input"), PinName); + InputObj->SetStringField(TEXT("chain"), Description); + InputObj->SetBoolField(TEXT("connected"), Pin->LinkedTo.Num() > 0); + InputDescriptions.Add(MakeShared(InputObj)); + } + + Result->SetBoolField(TEXT("success"), true); + Result->SetStringField(TEXT("material"), MaterialObj->GetName()); + Result->SetStringField(TEXT("materialPath"), MaterialObj->GetPathName()); + Result->SetArrayField(TEXT("inputs"), InputDescriptions); + + // Also include a compact text description + FString TextDesc; + for (const TSharedPtr& Val : InputDescriptions) + { + TSharedPtr 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); + } + } +}; + +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + +UCLASS(meta=(ToolName="search_within_materials")) +class UMCPHandler_SearchMaterials : public UObject, public IMCPHandler +{ + GENERATED_BODY() + +public: + UPROPERTY(meta=(Description="Search query string to match against material names, expression classes, and parameter names")) + FString Query; + + UPROPERTY(meta=(Optional, Description="Maximum number of results to return (default 50, max 200)")) + int32 MaxResults = 50; + + virtual FString GetDescription() const override + { + return TEXT("Search across all materials for matching material names, expression types, and parameter names."); + } + + virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override + { + FString DecodedQuery = MCPUtils::UrlDecode(Query); + + MaxResults = FMath::Clamp(MaxResults, 1, 200); + + TArray> Results; + + for (const FAssetData& Asset : UMCPAssetFinder::GetAssets(UMaterial::StaticClass())) + { + if (Results.Num() >= MaxResults) break; + + FString MatName = Asset.AssetName.ToString(); + + // Check material name first + bool bNameMatch = MatName.Contains(DecodedQuery, ESearchCase::IgnoreCase); + + UMaterial* MaterialObj = Cast(const_cast(Asset).GetAsset()); + if (!MaterialObj) continue; + + auto Expressions = MaterialObj->GetExpressions(); + + if (bNameMatch) + { + // Add a match for the material itself + TSharedRef R = MakeShared(); + R->SetStringField(TEXT("material"), MatName); + R->SetStringField(TEXT("materialPath"), Asset.PackageName.ToString()); + R->SetStringField(TEXT("matchType"), TEXT("materialName")); + Results.Add(MakeShared(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(Expr)) + ParamName = SP->ParameterName.ToString(); + else if (auto* VP = Cast(Expr)) + ParamName = VP->ParameterName.ToString(); + else if (auto* TP = Cast(Expr)) + ParamName = TP->ParameterName.ToString(); + else if (auto* SSP = Cast(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 R = MakeShared(); + 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(R)); + } + } + } + + Result->SetStringField(TEXT("query"), DecodedQuery); + Result->SetNumberField(TEXT("resultCount"), Results.Num()); + Result->SetArrayField(TEXT("results"), Results); + } +}; + +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + +UCLASS(meta=(ToolName="find_material_references")) +class UMCPHandler_FindMaterialReferences : public UObject, public IMCPHandler +{ + GENERATED_BODY() + +public: + UPROPERTY(meta=(Description="Material or MaterialInstance name or package path")) + FString Material; + + virtual FString GetDescription() const override + { + return TEXT("Find all assets that reference a given material or material instance."); + } + + virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override + { + // Try to find the material's package path + FString PackagePath; + FAssetData* MatAsset = UMCPAssetFinder::FindAsset(UMaterial::StaticClass(), Material); + if (MatAsset) + { + PackagePath = MatAsset->PackageName.ToString(); + } + else + { + // Try material instance + FAssetData* MIAsset = UMCPAssetFinder::FindAsset(UMaterialInstanceConstant::StaticClass(), Material); + if (MIAsset) + { + PackagePath = MIAsset->PackageName.ToString(); + } + } + + if (PackagePath.IsEmpty()) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Material '%s' not found. Use list_materials to see available assets."), *Material)); + } + + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: FindMaterialReferences — '%s' (package: %s)"), *Material, *PackagePath); + + IAssetRegistry& Registry = *IAssetRegistry::Get(); + + TArray Referencers; + Registry.GetReferencers(FName(*PackagePath), Referencers); + + TArray> RefArray; + for (const FName& Ref : Referencers) + { + FString RefStr = Ref.ToString(); + // Skip self-reference + if (RefStr == PackagePath) continue; + RefArray.Add(MakeShared(RefStr)); + } + + Result->SetBoolField(TEXT("success"), true); + Result->SetStringField(TEXT("material"), Material); + Result->SetStringField(TEXT("packagePath"), PackagePath); + Result->SetNumberField(TEXT("totalReferencers"), RefArray.Num()); + Result->SetArrayField(TEXT("referencers"), RefArray); + } +}; + +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + +UCLASS(meta=(ToolName="list_material_function_assets")) +class UMCPHandler_ListMaterialFunctions : public UObject, public IMCPHandler +{ + GENERATED_BODY() + +public: + UPROPERTY(meta=(Optional, Description="Filter string to match against function name or path")) + FString Filter; + + virtual FString GetDescription() const override + { + return TEXT("List MaterialFunction assets, optionally filtered by name or path."); + } + + virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override + { + TArray> Entries; + + for (const FAssetData& Asset : UMCPAssetFinder::GetAssets(UMaterialFunction::StaticClass())) + { + FString Name = Asset.AssetName.ToString(); + FString Path = Asset.PackageName.ToString(); + + if (!Filter.IsEmpty()) + { + if (!Name.Contains(Filter, ESearchCase::IgnoreCase) && + !Path.Contains(Filter, ESearchCase::IgnoreCase)) + { + continue; + } + } + + TSharedRef Entry = MakeShared(); + Entry->SetStringField(TEXT("name"), Name); + Entry->SetStringField(TEXT("path"), Path); + Entries.Add(MakeShared(Entry)); + } + + Result->SetNumberField(TEXT("count"), Entries.Num()); + Result->SetNumberField(TEXT("total"), UMCPAssetFinder::GetAssets(UMaterialFunction::StaticClass()).Num()); + Result->SetArrayField(TEXT("functions"), Entries); + } +}; + +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + +UCLASS(meta=(ToolName="dump_material_function")) +class UMCPHandler_GetMaterialFunction : public UObject, public IMCPHandler +{ + GENERATED_BODY() + +public: + UPROPERTY(meta=(Description="MaterialFunction name or package path")) + FString MaterialFunction; + + virtual FString GetDescription() const override + { + return TEXT("Get detailed info about a material function, including its inputs, outputs, and expressions."); + } + + virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override + { + FString DecodedName = MCPUtils::UrlDecode(MaterialFunction); + + UMaterialFunction* MF = UMCPAssetFinder::LoadAsset(DecodedName, Result); + if (!MF) return; + + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: GetMaterialFunction — '%s'"), *MF->GetName()); + + 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> Inputs; + TArray> Outputs; + TArray> ExpressionList; + + { + for (UMaterialExpression* Expr : Expressions) + { + if (!Expr) continue; + + if (auto* FI = Cast(Expr)) + { + TSharedRef InputObj = MakeShared(); + 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(InputObj)); + } + else if (auto* FO = Cast(Expr)) + { + TSharedRef OutputObj = MakeShared(); + 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(OutputObj)); + } + + // Serialize every expression + TSharedPtr ExprJson = MCPUtils::SerializeMaterialExpression(Expr); + if (ExprJson.IsValid()) + { + ExpressionList.Add(MakeShared(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 GraphJson = MCPUtils::SerializeGraph(FuncGraph); + if (GraphJson.IsValid()) + { + Result->SetObjectField(TEXT("graph"), GraphJson); + } + } + } +}; + +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + +UCLASS(meta=(ToolName="compile_material")) +class UMCPHandler_ValidateMaterial : public UObject, public IMCPHandler +{ + GENERATED_BODY() + +public: + UPROPERTY(meta=(Description="Material name or package path")) + FString Material; + + virtual FString GetDescription() const override + { + return TEXT("Force recompile a material and check for compilation errors."); + } + + virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override + { + // Load material + UMaterial* MaterialObj = UMCPAssetFinder::LoadAsset(Material, Result); + if (!MaterialObj) return; + + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Validating material '%s'"), *MaterialObj->GetName()); + + // Force recompile by triggering PreEditChange/PostEditChange + MaterialObj->PreEditChange(nullptr); + MaterialObj->PostEditChange(); + + // Collect compilation errors + TArray> ErrorArray; + bool bValid = true; + + // Check for compilation errors via FMaterialResource on current platform + FMaterialResource* Resource = MaterialObj->GetMaterialResource(GMaxRHIFeatureLevel); + if (Resource) + { + const TArray& CompileErrors = Resource->GetCompileErrors(); + for (const FString& Err : CompileErrors) + { + bValid = false; + ErrorArray.Add(MakeShared(Err)); + } + } + + // Count expressions and connections + auto Expressions = MaterialObj->GetExpressions(); + int32 ExprCount = Expressions.Num(); + int32 ConnectionCount = 0; + if (MaterialObj->MaterialGraph) + { + for (UEdGraphNode* Node : MaterialObj->MaterialGraph->Nodes) + { + if (!Node) continue; + for (UEdGraphPin* Pin : Node->Pins) + { + if (Pin && Pin->Direction == EGPD_Output) + { + ConnectionCount += Pin->LinkedTo.Num(); + } + } + } + } + + Result->SetBoolField(TEXT("valid"), bValid); + Result->SetStringField(TEXT("material"), MaterialObj->GetName()); + Result->SetStringField(TEXT("materialPath"), MaterialObj->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)"), + *MaterialObj->GetName(), bValid ? TEXT("passed") : TEXT("failed"), ErrorArray.Num()); + } +}; diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Mutation.h b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Mutation.h index 0a47baaf..7890df60 100644 --- a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Mutation.h +++ b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Mutation.h @@ -60,7 +60,7 @@ struct FMoveNodeEntry GENERATED_BODY() UPROPERTY() - FString NodeId; + FString Node; UPROPERTY() int32 X = 0; @@ -113,12 +113,12 @@ public: continue; } - EntryResult->SetStringField(TEXT("nodeId"), Entry.NodeId); + EntryResult->SetStringField(TEXT("nodeId"), Entry.Node); - UEdGraphNode* Node = MCPUtils::FindNodeByGuid(BP, Entry.NodeId); + UEdGraphNode* Node = MCPUtils::FindNodeByGuid(BP, Entry.Node); if (!Node) { - EntryResult->SetStringField(TEXT("error"), FString::Printf(TEXT("Node '%s' not found"), *Entry.NodeId)); + EntryResult->SetStringField(TEXT("error"), FString::Printf(TEXT("Node '%s' not found"), *Entry.Node)); continue; } @@ -164,7 +164,7 @@ public: FString Graph; UPROPERTY(meta=(Description="Array of node GUIDs to duplicate")) - FMCPJsonArray NodeIds; + FMCPJsonArray Nodes; UPROPERTY(meta=(Optional, Description="X offset for duplicated nodes")) int32 OffsetX = 50; @@ -209,7 +209,7 @@ public: return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Graph '%s' not found"), *DecodedGraphName)); } - if (NodeIds.Array.Num() == 0) + if (Nodes.Array.Num() == 0) { return MCPUtils::MakeErrorJson(Result, TEXT("nodeIds array is empty")); } @@ -218,24 +218,24 @@ public: TArray SourceNodes; TArray NotFound; - for (const TSharedPtr& IdVal : NodeIds.Array) + for (const TSharedPtr& IdVal : Nodes.Array) { - FString NodeId = IdVal->AsString(); - UEdGraphNode* Node = MCPUtils::FindNodeByGuid(BP, NodeId); - if (Node) + FString Node = IdVal->AsString(); + UEdGraphNode* FoundNode = MCPUtils::FindNodeByGuid(BP, Node); + if (FoundNode) { - if (Node->GetGraph() == TargetGraph) + if (FoundNode->GetGraph() == TargetGraph) { - SourceNodes.Add(Node); + SourceNodes.Add(FoundNode); } else { - NotFound.Add(FString::Printf(TEXT("%s (in different graph)"), *NodeId)); + NotFound.Add(FString::Printf(TEXT("%s (in different graph)"), *Node)); } } else { - NotFound.Add(NodeId); + NotFound.Add(Node); } } @@ -494,7 +494,7 @@ public: FString Blueprint; UPROPERTY(meta=(Description="Node GUID")) - FString NodeId; + FString Node; virtual FString GetDescription() const override { @@ -511,17 +511,17 @@ public: return MCPUtils::MakeErrorJson(Result, LoadError); } - UEdGraphNode* Node = MCPUtils::FindNodeByGuid(BP, NodeId); - if (!Node) + UEdGraphNode* FoundNode = MCPUtils::FindNodeByGuid(BP, Node); + if (!FoundNode) { - return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Node '%s' not found"), *NodeId)); + return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Node '%s' not found"), *Node)); } Result->SetBoolField(TEXT("success"), true); Result->SetStringField(TEXT("blueprint"), Blueprint); - Result->SetStringField(TEXT("nodeId"), NodeId); - Result->SetStringField(TEXT("comment"), Node->NodeComment); - Result->SetBoolField(TEXT("commentBubbleVisible"), Node->bCommentBubbleVisible); + Result->SetStringField(TEXT("nodeId"), Node); + Result->SetStringField(TEXT("comment"), FoundNode->NodeComment); + Result->SetBoolField(TEXT("commentBubbleVisible"), FoundNode->bCommentBubbleVisible); } }; @@ -539,7 +539,7 @@ public: FString Blueprint; UPROPERTY(meta=(Description="Node GUID")) - FString NodeId; + FString Node; UPROPERTY(meta=(Description="Comment text to set")) FString Comment; @@ -559,30 +559,30 @@ public: return MCPUtils::MakeErrorJson(Result, LoadError); } - UEdGraphNode* Node = MCPUtils::FindNodeByGuid(BP, NodeId); - if (!Node) + UEdGraphNode* FoundNode = MCPUtils::FindNodeByGuid(BP, Node); + if (!FoundNode) { - return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Node '%s' not found"), *NodeId)); + return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Node '%s' not found"), *Node)); } - FString OldComment = Node->NodeComment; - Node->NodeComment = Comment; + FString OldComment = FoundNode->NodeComment; + FoundNode->NodeComment = Comment; // Make the comment bubble visible if setting a non-empty comment if (!Comment.IsEmpty()) { - Node->bCommentBubbleVisible = true; - Node->bCommentBubblePinned = true; + FoundNode->bCommentBubbleVisible = true; + FoundNode->bCommentBubblePinned = true; } FBlueprintEditorUtils::MarkBlueprintAsModified(BP); UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Set comment on node '%s' in '%s'"), - *NodeId, *Blueprint); + *Node, *Blueprint); Result->SetBoolField(TEXT("success"), true); Result->SetStringField(TEXT("blueprint"), Blueprint); - Result->SetStringField(TEXT("nodeId"), NodeId); + Result->SetStringField(TEXT("nodeId"), Node); Result->SetStringField(TEXT("oldComment"), OldComment); Result->SetStringField(TEXT("newComment"), Comment); } @@ -602,7 +602,7 @@ public: FString Blueprint; UPROPERTY(meta=(Description="Node GUID")) - FString NodeId; + FString Node; virtual FString GetDescription() const override { @@ -621,24 +621,24 @@ public: } UEdGraph* Graph = nullptr; - UEdGraphNode* Node = MCPUtils::FindNodeByGuid(BP, NodeId, &Graph); - if (!Node) + UEdGraphNode* FoundNode = MCPUtils::FindNodeByGuid(BP, Node, &Graph); + if (!FoundNode) { - return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Node '%s' not found"), *NodeId)); + return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Node '%s' not found"), *Node)); } if (!Graph) { - return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Graph not found for node '%s'"), *NodeId)); + return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Graph not found for node '%s'"), *Node)); } - FString NodeClass = Node->GetClass()->GetName(); - FString NodeTitle = Node->GetNodeTitle(ENodeTitleType::FullTitle).ToString(); + FString NodeClass = FoundNode->GetClass()->GetName(); + FString NodeTitle = FoundNode->GetNodeTitle(ENodeTitleType::FullTitle).ToString(); FString GraphName = Graph->GetName(); // Protect root/entry nodes — deleting these leaves the graph in an invalid // state with no root node, causing compiler errors that can't be fixed // without recreating the entire function/event. - if (Cast(Node)) + if (Cast(FoundNode)) { return MCPUtils::MakeErrorJson(Result, FString::Printf( TEXT("Cannot delete FunctionEntry node '%s' in graph '%s'. ") @@ -646,14 +646,14 @@ public: TEXT("To remove the entire function, delete it from the Blueprint editor."), *NodeTitle, *GraphName)); } - if (Cast(Node)) + if (Cast(FoundNode)) { return MCPUtils::MakeErrorJson(Result, FString::Printf( TEXT("Cannot delete event entry node '%s' in graph '%s'. ") TEXT("This is the root node of the event handler — removing it would leave an empty, uncompilable graph."), *NodeTitle, *GraphName)); } - if (Cast(Node)) + if (Cast(FoundNode)) { return MCPUtils::MakeErrorJson(Result, FString::Printf( TEXT("Cannot delete CustomEvent entry node '%s' in graph '%s'. ") @@ -662,10 +662,10 @@ public: } UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Deleting node '%s' (%s) from graph '%s' in '%s'"), - *NodeId, *NodeTitle, *GraphName, *Blueprint); + *Node, *NodeTitle, *GraphName, *Blueprint); - Node->BreakAllNodeLinks(); - Graph->RemoveNode(Node); + FoundNode->BreakAllNodeLinks(); + Graph->RemoveNode(FoundNode); FBlueprintEditorUtils::MarkBlueprintAsStructurallyModified(BP); @@ -673,7 +673,7 @@ public: Result->SetBoolField(TEXT("success"), true); Result->SetStringField(TEXT("blueprint"), Blueprint); - Result->SetStringField(TEXT("nodeId"), NodeId); + Result->SetStringField(TEXT("nodeId"), Node); Result->SetStringField(TEXT("nodeClass"), NodeClass); Result->SetStringField(TEXT("nodeTitle"), NodeTitle); Result->SetStringField(TEXT("graph"), GraphName); @@ -1077,7 +1077,7 @@ public: FString Blueprint; UPROPERTY(meta=(Description="Node GUID of the BreakStruct or MakeStruct node")) - FString NodeId; + FString Node; UPROPERTY(meta=(Description="New struct type name (e.g. 'FVector', 'Vector')")) FString NewType; @@ -1101,20 +1101,20 @@ public: // Find node UEdGraph* Graph = nullptr; - UEdGraphNode* Node = MCPUtils::FindNodeByGuid(BP, NodeId, &Graph); - if (!Node) + UEdGraphNode* FoundNode = MCPUtils::FindNodeByGuid(BP, Node, &Graph); + if (!FoundNode) { - return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Node '%s' not found"), *NodeId)); + return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Node '%s' not found"), *Node)); } // Determine what kind of struct node this is - UK2Node_BreakStruct* BreakNode = Cast(Node); - UK2Node_MakeStruct* MakeNode = Cast(Node); + UK2Node_BreakStruct* BreakNode = Cast(FoundNode); + UK2Node_MakeStruct* MakeNode = Cast(FoundNode); if (!BreakNode && !MakeNode) { return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Node '%s' is not a BreakStruct or MakeStruct node (class: %s)"), - *NodeId, *Node->GetClass()->GetName())); + *Node, *FoundNode->GetClass()->GetName())); } // Find the new struct type @@ -1136,7 +1136,7 @@ public: } UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Changing struct node '%s' to type '%s'"), - *NodeId, *NewStruct->GetName()); + *Node, *NewStruct->GetName()); // Helper: extract property base name from a BreakStruct pin name auto ExtractPropertyBaseName = [](const FString& PinName) -> FString @@ -1172,7 +1172,7 @@ public: }; TMap ConnectionsByBaseName; - for (UEdGraphPin* Pin : Node->Pins) + for (UEdGraphPin* Pin : FoundNode->Pins) { if (!Pin || Pin->LinkedTo.Num() == 0) continue; if (Pin->PinType.PinCategory == UEdGraphSchema_K2::PC_Exec) continue; @@ -1196,7 +1196,7 @@ public: } // Break all existing links before reconstruction - Node->BreakAllNodeLinks(); + FoundNode->BreakAllNodeLinks(); // Reconnect pins by matching property base names const UEdGraphSchema* Schema = Graph->GetSchema(); @@ -1206,7 +1206,7 @@ public: } // Reconstruct to rebuild pins for the new struct type (use schema version for MinimalAPI compat) - Schema->ReconstructNode(*Node); + Schema->ReconstructNode(*FoundNode); int32 Reconnected = 0; int32 Failed = 0; @@ -1219,7 +1219,7 @@ public: // Find matching new pin UEdGraphPin* NewPin = nullptr; - for (UEdGraphPin* Pin : Node->Pins) + for (UEdGraphPin* Pin : FoundNode->Pins) { if (!Pin || Pin->Direction != OldConn.Direction) continue; FString NewBaseName = ExtractPropertyBaseName(Pin->PinName.ToString()); @@ -1233,7 +1233,7 @@ public: // Also try matching the struct input/output pin (single struct pin) if (!NewPin) { - for (UEdGraphPin* Pin : Node->Pins) + for (UEdGraphPin* Pin : FoundNode->Pins) { if (!Pin || Pin->Direction != OldConn.Direction) continue; if ((Pin->PinType.PinCategory == UEdGraphSchema_K2::PC_Struct) && @@ -1279,13 +1279,13 @@ public: FBlueprintEditorUtils::MarkBlueprintAsStructurallyModified(BP); // Return updated node state - TSharedPtr UpdatedNodeState = MCPUtils::SerializeNode(Node); + TSharedPtr UpdatedNodeState = MCPUtils::SerializeNode(FoundNode); Result->SetBoolField(TEXT("success"), true); Result->SetStringField(TEXT("blueprint"), Blueprint); - Result->SetStringField(TEXT("nodeId"), NodeId); + Result->SetStringField(TEXT("nodeId"), Node); Result->SetStringField(TEXT("newStructType"), NewStruct->GetName()); - Result->SetStringField(TEXT("nodeClass"), Node->GetClass()->GetName()); + Result->SetStringField(TEXT("nodeClass"), FoundNode->GetClass()->GetName()); Result->SetNumberField(TEXT("reconnected"), Reconnected); Result->SetNumberField(TEXT("failed"), Failed); Result->SetArrayField(TEXT("reconnectDetails"), ReconnectDetails); diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Params.cpp b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Params.cpp deleted file mode 100644 index 2312ed63..00000000 --- a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Params.cpp +++ /dev/null @@ -1,567 +0,0 @@ -#include "MCPAssetFinder.h" -#include "MCPServer.h" -#include "MCPUtils.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" - -void FBlueprintMCPServer::HandleChangeFunctionParamType(const FJsonObject* Json, FJsonObject* Result) -{ - 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 MCPUtils::MakeErrorJson(Result, TEXT("Missing required fields: blueprint, functionName, paramName, newType")); - } - - // Load Blueprint - FString LoadError; - UBlueprint* BP = UMCPAssetFinder::LoadBlueprintOrLevelBlueprint(BlueprintName, LoadError); - if (!BP) - { - return MCPUtils::MakeErrorJson(Result, LoadError); - } - - // Resolve the new type using the shared resolver (supports primitives, structs, enums, and object references) - FEdGraphPinType NewPinType; - if (!MCPUtils::ResolveTypeFromString(NewTypeName, NewPinType, Result)) - return; - - // Find the entry node: K2Node_FunctionEntry in a function graph, - // or K2Node_CustomEvent in any graph - UK2Node_EditablePinBase* EntryNode = nullptr; - FString FoundNodeType; - - TArray 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(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(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> Available; - for (UEdGraph* Graph : AllGraphs) - { - if (!Graph) continue; - for (UEdGraphNode* Node : Graph->Nodes) - { - if (UK2Node_FunctionEntry* FE = Cast(Node)) - { - Available.Add(MakeShared( - FString::Printf(TEXT("function:%s"), *Graph->GetName()))); - } - else if (UK2Node_CustomEvent* CE = Cast(Node)) - { - Available.Add(MakeShared( - FString::Printf(TEXT("event:%s"), *CE->CustomFunctionName.ToString()))); - } - } - } - - MCPUtils::MakeErrorJson(Result, FString::Printf( - TEXT("Function or custom event '%s' not found in Blueprint '%s'"), - *FunctionName, *BlueprintName)); - Result->SetArrayField(TEXT("availableFunctionsAndEvents"), Available); - return; - } - - // Find the UserDefinedPin matching paramName - bool bPinFound = false; - for (TSharedPtr& 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> ParamNames; - for (const TSharedPtr& PinInfo : EntryNode->UserDefinedPins) - { - if (PinInfo.IsValid()) - { - ParamNames.Add(MakeShared(PinInfo->PinName.ToString())); - } - } - - MCPUtils::MakeErrorJson(Result, FString::Printf( - TEXT("Parameter '%s' not found in %s '%s'"), - *ParamName, *FoundNodeType, *FunctionName)); - Result->SetArrayField(TEXT("availableParams"), ParamNames); - return; - } - - // 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> 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 AffPin = MakeShared(); - 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(AffPin)); - } - } - } - } - - 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; - } - - 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 = MCPUtils::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 UpdatedNodeState = MCPUtils::SerializeNode(EntryNode); - - 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); - } -} - -// ============================================================ -// HandleRemoveFunctionParameter -// ============================================================ - -void FBlueprintMCPServer::HandleRemoveFunctionParameter(const FJsonObject* Json, FJsonObject* Result) -{ - 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 MCPUtils::MakeErrorJson(Result, TEXT("Missing required fields: blueprint, functionName, paramName")); - } - - // Load Blueprint - FString LoadError; - UBlueprint* BP = UMCPAssetFinder::LoadBlueprintOrLevelBlueprint(BlueprintName, LoadError); - if (!BP) - { - return MCPUtils::MakeErrorJson(Result, LoadError); - } - - // Find the entry node - UK2Node_EditablePinBase* EntryNode = nullptr; - FString FoundNodeType; - - TArray 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(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(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> Available; - for (UEdGraph* Graph : AllGraphs) - { - if (!Graph) continue; - for (UEdGraphNode* Node : Graph->Nodes) - { - if (UK2Node_FunctionEntry* FE = Cast(Node)) - { - Available.Add(MakeShared( - FString::Printf(TEXT("function:%s"), *Graph->GetName()))); - } - else if (UK2Node_CustomEvent* CE = Cast(Node)) - { - Available.Add(MakeShared( - FString::Printf(TEXT("event:%s"), *CE->CustomFunctionName.ToString()))); - } - } - } - - MCPUtils::MakeErrorJson(Result, FString::Printf( - TEXT("Function or custom event '%s' not found in Blueprint '%s'"), - *FunctionName, *BlueprintName)); - Result->SetArrayField(TEXT("availableFunctionsAndEvents"), Available); - return; - } - - // 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> ParamNames; - for (const TSharedPtr& PinInfo : EntryNode->UserDefinedPins) - { - if (PinInfo.IsValid()) - { - ParamNames.Add(MakeShared(PinInfo->PinName.ToString())); - } - } - - MCPUtils::MakeErrorJson(Result, FString::Printf( - TEXT("Parameter '%s' not found in %s '%s'"), - *ParamName, *FoundNodeType, *FunctionName)); - Result->SetArrayField(TEXT("availableParams"), ParamNames); - return; - } - - 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 = MCPUtils::SaveBlueprintPackage(BP); - - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Parameter removed, save %s"), - bSaved ? TEXT("succeeded") : TEXT("failed")); - - 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); -} - -// ============================================================ -// HandleAddFunctionParameter -// ============================================================ - -void FBlueprintMCPServer::HandleAddFunctionParameter(const FJsonObject* Json, FJsonObject* Result) -{ - 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 MCPUtils::MakeErrorJson(Result, TEXT("Missing required fields: blueprint, functionName, paramName, paramType")); - } - - // Load Blueprint - FString LoadError; - UBlueprint* BP = UMCPAssetFinder::LoadBlueprintOrLevelBlueprint(BlueprintName, LoadError); - if (!BP) - { - return MCPUtils::MakeErrorJson(Result, LoadError); - } - - // Resolve param type - FEdGraphPinType PinType; - if (!MCPUtils::ResolveTypeFromString(ParamType, PinType, Result)) - return; - - // Find the entry node using 3 strategies - UK2Node_EditablePinBase* EntryNode = nullptr; - FString NodeType; - - FName FuncFName(*FunctionName); - - // Strategy 1: K2Node_FunctionEntry in function graphs - TArray 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(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(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(Node)) - { - EntryNode = FE; - NodeType = TEXT("EventDispatcher"); - break; - } - } - if (EntryNode) break; - } - } - - if (!EntryNode) - { - // Build a helpful error listing available functions, events, and dispatchers - TArray> AvailFuncs; - - for (UEdGraph* Graph : BP->FunctionGraphs) - { - if (Graph) AvailFuncs.Add(MakeShared(Graph->GetName())); - } - - // Custom events - for (UEdGraph* Graph : AllGraphs) - { - if (!Graph) continue; - for (UEdGraphNode* Node : Graph->Nodes) - { - if (UK2Node_CustomEvent* CE = Cast(Node)) - { - AvailFuncs.Add(MakeShared( - FString::Printf(TEXT("%s (custom event)"), *CE->CustomFunctionName.ToString()))); - } - } - } - - // Dispatchers - TSet DelegateNames; - FBlueprintEditorUtils::GetDelegateNameList(BP, DelegateNames); - for (const FName& DN : DelegateNames) - { - AvailFuncs.Add(MakeShared( - FString::Printf(TEXT("%s (event dispatcher)"), *DN.ToString()))); - } - - MCPUtils::MakeErrorJson(Result, FString::Printf( - TEXT("Function, custom event, or event dispatcher '%s' not found in Blueprint '%s'"), - *FunctionName, *BlueprintName)); - Result->SetArrayField(TEXT("availableFunctions"), AvailFuncs); - return; - } - - // Check for duplicate parameter name - for (const TSharedPtr& Existing : EntryNode->UserDefinedPins) - { - if (Existing.IsValid() && Existing->PinName.ToString().Equals(ParamName, ESearchCase::IgnoreCase)) - { - return MCPUtils::MakeErrorJson(Result, 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 = MCPUtils::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")); - - 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); -} diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Params.h b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Params.h new file mode 100644 index 00000000..dd0f8c0d --- /dev/null +++ b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Params.h @@ -0,0 +1,606 @@ +#pragma once + +#include "CoreMinimal.h" +#include "MCPHandler.h" +#include "MCPAssetFinder.h" +#include "MCPUtils.h" +#include "Engine/Blueprint.h" +#include "EdGraph/EdGraph.h" +#include "EdGraph/EdGraphPin.h" +#include "K2Node_FunctionEntry.h" +#include "K2Node_CustomEvent.h" +#include "K2Node_EditablePinBase.h" +#include "Kismet2/BlueprintEditorUtils.h" +#include "MCPHandlers_Params.generated.h" + +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + +UCLASS(meta=(ToolName="change_function_parameter_type")) +class UMCPHandler_ChangeFunctionParamType : public UObject, public IMCPHandler +{ + GENERATED_BODY() + +public: + UPROPERTY(meta=(Description="Blueprint name or package path")) + FString Blueprint; + + UPROPERTY(meta=(Description="Name of the function or custom event")) + FString FunctionName; + + UPROPERTY(meta=(Description="Name of the parameter to change")) + FString ParamName; + + UPROPERTY(meta=(Description="New type for the parameter (e.g. 'Float', 'Vector', 'MyStruct')")) + FString NewType; + + UPROPERTY(meta=(Optional, Description="If true, analyze impact without making changes")) + bool DryRun = false; + + virtual FString GetDescription() const override + { + return TEXT("Change the type of an existing parameter on a function or custom event in a Blueprint."); + } + + virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override + { + // Load Blueprint + FString LoadError; + UBlueprint* BP = UMCPAssetFinder::LoadBlueprintOrLevelBlueprint(Blueprint, LoadError); + if (!BP) + { + return MCPUtils::MakeErrorJson(Result, LoadError); + } + + // Resolve the new type using the shared resolver (supports primitives, structs, enums, and object references) + FEdGraphPinType NewPinType; + if (!MCPUtils::ResolveTypeFromString(NewType, NewPinType, Result)) + return; + + // Find the entry node: K2Node_FunctionEntry in a function graph, + // or K2Node_CustomEvent in any graph + UK2Node_EditablePinBase* EntryNode = nullptr; + FString FoundNodeType; + + TArray 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(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(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> Available; + for (UEdGraph* Graph : AllGraphs) + { + if (!Graph) continue; + for (UEdGraphNode* Node : Graph->Nodes) + { + if (UK2Node_FunctionEntry* FE = Cast(Node)) + { + Available.Add(MakeShared( + FString::Printf(TEXT("function:%s"), *Graph->GetName()))); + } + else if (UK2Node_CustomEvent* CE = Cast(Node)) + { + Available.Add(MakeShared( + FString::Printf(TEXT("event:%s"), *CE->CustomFunctionName.ToString()))); + } + } + } + + MCPUtils::MakeErrorJson(Result, FString::Printf( + TEXT("Function or custom event '%s' not found in Blueprint '%s'"), + *FunctionName, *Blueprint)); + Result->SetArrayField(TEXT("availableFunctionsAndEvents"), Available); + return; + } + + // Find the UserDefinedPin matching paramName + bool bPinFound = false; + for (TSharedPtr& 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> ParamNames; + for (const TSharedPtr& PinInfo : EntryNode->UserDefinedPins) + { + if (PinInfo.IsValid()) + { + ParamNames.Add(MakeShared(PinInfo->PinName.ToString())); + } + } + + MCPUtils::MakeErrorJson(Result, FString::Printf( + TEXT("Parameter '%s' not found in %s '%s'"), + *ParamName, *FoundNodeType, *FunctionName)); + Result->SetArrayField(TEXT("availableParams"), ParamNames); + return; + } + + // Check for dry run + if (DryRun) + { + // Analyze what would change: report connected pins that may disconnect + TArray> 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 AffPin = MakeShared(); + 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(AffPin)); + } + } + } + } + + Result->SetBoolField(TEXT("dryRun"), true); + Result->SetStringField(TEXT("blueprint"), Blueprint); + Result->SetStringField(TEXT("functionName"), FunctionName); + Result->SetStringField(TEXT("paramName"), ParamName); + Result->SetStringField(TEXT("newType"), NewType); + Result->SetStringField(TEXT("nodeType"), FoundNodeType); + Result->SetStringField(TEXT("nodeId"), EntryNode->NodeGuid.ToString()); + Result->SetNumberField(TEXT("connectionsAtRisk"), AffectedPins.Num()); + Result->SetArrayField(TEXT("affectedPins"), AffectedPins); + return; + } + + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Changing param '%s' in %s '%s' of '%s' to %s"), + *ParamName, *FoundNodeType, *FunctionName, *Blueprint, *NewType); + + // 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 = MCPUtils::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 UpdatedNodeState = MCPUtils::SerializeNode(EntryNode); + + Result->SetBoolField(TEXT("success"), true); + Result->SetStringField(TEXT("blueprint"), Blueprint); + Result->SetStringField(TEXT("functionName"), FunctionName); + Result->SetStringField(TEXT("paramName"), ParamName); + Result->SetStringField(TEXT("newType"), NewType); + 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); + } + } +}; + +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + +UCLASS(meta=(ToolName="remove_function_parameter")) +class UMCPHandler_RemoveFunctionParameter : public UObject, public IMCPHandler +{ + GENERATED_BODY() + +public: + UPROPERTY(meta=(Description="Blueprint name or package path")) + FString Blueprint; + + UPROPERTY(meta=(Description="Name of the function or custom event")) + FString FunctionName; + + UPROPERTY(meta=(Description="Name of the parameter to remove")) + FString ParamName; + + virtual FString GetDescription() const override + { + return TEXT("Remove a parameter from a function or custom event in a Blueprint."); + } + + virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override + { + // Load Blueprint + FString LoadError; + UBlueprint* BP = UMCPAssetFinder::LoadBlueprintOrLevelBlueprint(Blueprint, LoadError); + if (!BP) + { + return MCPUtils::MakeErrorJson(Result, LoadError); + } + + // Find the entry node + UK2Node_EditablePinBase* EntryNode = nullptr; + FString FoundNodeType; + + TArray 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(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(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> Available; + for (UEdGraph* Graph : AllGraphs) + { + if (!Graph) continue; + for (UEdGraphNode* Node : Graph->Nodes) + { + if (UK2Node_FunctionEntry* FE = Cast(Node)) + { + Available.Add(MakeShared( + FString::Printf(TEXT("function:%s"), *Graph->GetName()))); + } + else if (UK2Node_CustomEvent* CE = Cast(Node)) + { + Available.Add(MakeShared( + FString::Printf(TEXT("event:%s"), *CE->CustomFunctionName.ToString()))); + } + } + } + + MCPUtils::MakeErrorJson(Result, FString::Printf( + TEXT("Function or custom event '%s' not found in Blueprint '%s'"), + *FunctionName, *Blueprint)); + Result->SetArrayField(TEXT("availableFunctionsAndEvents"), Available); + return; + } + + // 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> ParamNames; + for (const TSharedPtr& PinInfo : EntryNode->UserDefinedPins) + { + if (PinInfo.IsValid()) + { + ParamNames.Add(MakeShared(PinInfo->PinName.ToString())); + } + } + + MCPUtils::MakeErrorJson(Result, FString::Printf( + TEXT("Parameter '%s' not found in %s '%s'"), + *ParamName, *FoundNodeType, *FunctionName)); + Result->SetArrayField(TEXT("availableParams"), ParamNames); + return; + } + + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Removing param '%s' from %s '%s' in '%s'"), + *ParamName, *FoundNodeType, *FunctionName, *Blueprint); + + // 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 = MCPUtils::SaveBlueprintPackage(BP); + + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Parameter removed, save %s"), + bSaved ? TEXT("succeeded") : TEXT("failed")); + + Result->SetBoolField(TEXT("success"), true); + Result->SetStringField(TEXT("blueprint"), Blueprint); + 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); + } +}; + +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + +UCLASS(meta=(ToolName="add_function_parameter")) +class UMCPHandler_AddFunctionParameter : public UObject, public IMCPHandler +{ + GENERATED_BODY() + +public: + UPROPERTY(meta=(Description="Blueprint name or package path")) + FString Blueprint; + + UPROPERTY(meta=(Description="Name of the function, custom event, or event dispatcher")) + FString FunctionName; + + UPROPERTY(meta=(Description="Name for the new parameter")) + FString ParamName; + + UPROPERTY(meta=(Description="Type for the new parameter (e.g. 'Float', 'Vector', 'MyStruct')")) + FString ParamType; + + virtual FString GetDescription() const override + { + return TEXT("Add a new parameter to a function, custom event, or event dispatcher in a Blueprint."); + } + + virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override + { + // Load Blueprint + FString LoadError; + UBlueprint* BP = UMCPAssetFinder::LoadBlueprintOrLevelBlueprint(Blueprint, LoadError); + if (!BP) + { + return MCPUtils::MakeErrorJson(Result, LoadError); + } + + // Resolve param type + FEdGraphPinType PinType; + if (!MCPUtils::ResolveTypeFromString(ParamType, PinType, Result)) + return; + + // Find the entry node using 3 strategies + UK2Node_EditablePinBase* EntryNode = nullptr; + FString NodeType; + + FName FuncFName(*FunctionName); + + // Strategy 1: K2Node_FunctionEntry in function graphs + TArray 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(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(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(Node)) + { + EntryNode = FE; + NodeType = TEXT("EventDispatcher"); + break; + } + } + if (EntryNode) break; + } + } + + if (!EntryNode) + { + // Build a helpful error listing available functions, events, and dispatchers + TArray> AvailFuncs; + + for (UEdGraph* Graph : BP->FunctionGraphs) + { + if (Graph) AvailFuncs.Add(MakeShared(Graph->GetName())); + } + + // Custom events + for (UEdGraph* Graph : AllGraphs) + { + if (!Graph) continue; + for (UEdGraphNode* Node : Graph->Nodes) + { + if (UK2Node_CustomEvent* CE = Cast(Node)) + { + AvailFuncs.Add(MakeShared( + FString::Printf(TEXT("%s (custom event)"), *CE->CustomFunctionName.ToString()))); + } + } + } + + // Dispatchers + TSet DelegateNames; + FBlueprintEditorUtils::GetDelegateNameList(BP, DelegateNames); + for (const FName& DN : DelegateNames) + { + AvailFuncs.Add(MakeShared( + FString::Printf(TEXT("%s (event dispatcher)"), *DN.ToString()))); + } + + MCPUtils::MakeErrorJson(Result, FString::Printf( + TEXT("Function, custom event, or event dispatcher '%s' not found in Blueprint '%s'"), + *FunctionName, *Blueprint)); + Result->SetArrayField(TEXT("availableFunctions"), AvailFuncs); + return; + } + + // Check for duplicate parameter name + for (const TSharedPtr& Existing : EntryNode->UserDefinedPins) + { + if (Existing.IsValid() && Existing->PinName.ToString().Equals(ParamName, ESearchCase::IgnoreCase)) + { + return MCPUtils::MakeErrorJson(Result, 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, *Blueprint); + + // Add the parameter pin (EGPD_Output on entry = input to callers) + EntryNode->CreateUserDefinedPin(FName(*ParamName), PinType, EGPD_Output); + + FBlueprintEditorUtils::MarkBlueprintAsStructurallyModified(BP); + bool bSaved = MCPUtils::SaveBlueprintPackage(BP); + + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Added parameter '%s' to '%s' in '%s' (saved: %s)"), + *ParamName, *FunctionName, *Blueprint, bSaved ? TEXT("true") : TEXT("false")); + + Result->SetBoolField(TEXT("success"), true); + Result->SetStringField(TEXT("blueprint"), Blueprint); + 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); + } +}; diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_PinMutation.h b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_PinMutation.h index 3b2502ce..6a7b379b 100644 --- a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_PinMutation.h +++ b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_PinMutation.h @@ -24,7 +24,7 @@ struct FSetPinDefaultEntry FString Blueprint; UPROPERTY() - FString NodeId; + FString Node; UPROPERTY() FString PinName; @@ -40,7 +40,7 @@ class UMCPHandler_SetPinDefaultValues : public UObject, public IMCPHandler GENERATED_BODY() public: - UPROPERTY(meta=(Description="Array of {blueprint, nodeId, pinName, value} objects")) + UPROPERTY(meta=(Description="Array of {blueprint, node, pinName, value} objects")) FMCPJsonArray Pins; virtual FString GetDescription() const override @@ -70,7 +70,7 @@ public: } EntryResult->SetStringField(TEXT("blueprint"), Entry.Blueprint); - EntryResult->SetStringField(TEXT("nodeId"), Entry.NodeId); + EntryResult->SetStringField(TEXT("nodeId"), Entry.Node); EntryResult->SetStringField(TEXT("pinName"), Entry.PinName); FString LoadError; @@ -82,17 +82,17 @@ public: } UEdGraph* Graph = nullptr; - UEdGraphNode* Node = MCPUtils::FindNodeByGuid(BP, Entry.NodeId, &Graph); + UEdGraphNode* Node = MCPUtils::FindNodeByGuid(BP, Entry.Node, &Graph); if (!Node) { - EntryResult->SetStringField(TEXT("error"), FString::Printf(TEXT("Node '%s' not found"), *Entry.NodeId)); + EntryResult->SetStringField(TEXT("error"), FString::Printf(TEXT("Node '%s' not found"), *Entry.Node)); continue; } UEdGraphPin* Pin = Node->FindPin(FName(*Entry.PinName)); if (!Pin) { - EntryResult->SetStringField(TEXT("error"), FString::Printf(TEXT("Pin '%s' not found on node '%s'"), *Entry.PinName, *Entry.NodeId)); + EntryResult->SetStringField(TEXT("error"), FString::Printf(TEXT("Pin '%s' not found on node '%s'"), *Entry.PinName, *Entry.Node)); continue; } @@ -155,13 +155,13 @@ struct FConnectPinsEntry GENERATED_BODY() UPROPERTY() - FString SourceNodeId; + FString SourceNode; UPROPERTY() FString SourcePinName; UPROPERTY() - FString TargetNodeId; + FString TargetNode; UPROPERTY() FString TargetPinName; @@ -177,7 +177,7 @@ public: UPROPERTY(meta=(Description="Blueprint name or package path")) FString Blueprint; - UPROPERTY(meta=(Description="Array of {sourceNodeId, sourcePinName, targetNodeId, targetPinName} objects")) + UPROPERTY(meta=(Description="Array of {sourceNode, sourcePinName, targetNode, targetPinName} objects")) FMCPJsonArray Connections; virtual FString GetDescription() const override @@ -211,37 +211,37 @@ public: continue; } - EntryResult->SetStringField(TEXT("sourceNodeId"), Entry.SourceNodeId); + EntryResult->SetStringField(TEXT("sourceNodeId"), Entry.SourceNode); EntryResult->SetStringField(TEXT("sourcePinName"), Entry.SourcePinName); - EntryResult->SetStringField(TEXT("targetNodeId"), Entry.TargetNodeId); + EntryResult->SetStringField(TEXT("targetNodeId"), Entry.TargetNode); EntryResult->SetStringField(TEXT("targetPinName"), Entry.TargetPinName); UEdGraph* SourceGraph = nullptr; - UEdGraphNode* SourceNode = MCPUtils::FindNodeByGuid(BP, Entry.SourceNodeId, &SourceGraph); + UEdGraphNode* SourceNode = MCPUtils::FindNodeByGuid(BP, Entry.SourceNode, &SourceGraph); if (!SourceNode) { - EntryResult->SetStringField(TEXT("error"), FString::Printf(TEXT("Source node '%s' not found"), *Entry.SourceNodeId)); + EntryResult->SetStringField(TEXT("error"), FString::Printf(TEXT("Source node '%s' not found"), *Entry.SourceNode)); continue; } - UEdGraphNode* TargetNode = MCPUtils::FindNodeByGuid(BP, Entry.TargetNodeId); + UEdGraphNode* TargetNode = MCPUtils::FindNodeByGuid(BP, Entry.TargetNode); if (!TargetNode) { - EntryResult->SetStringField(TEXT("error"), FString::Printf(TEXT("Target node '%s' not found"), *Entry.TargetNodeId)); + EntryResult->SetStringField(TEXT("error"), FString::Printf(TEXT("Target node '%s' not found"), *Entry.TargetNode)); continue; } UEdGraphPin* SourcePin = SourceNode->FindPin(FName(*Entry.SourcePinName)); if (!SourcePin) { - EntryResult->SetStringField(TEXT("error"), FString::Printf(TEXT("Source pin '%s' not found on node '%s'"), *Entry.SourcePinName, *Entry.SourceNodeId)); + EntryResult->SetStringField(TEXT("error"), FString::Printf(TEXT("Source pin '%s' not found on node '%s'"), *Entry.SourcePinName, *Entry.SourceNode)); continue; } UEdGraphPin* TargetPin = TargetNode->FindPin(FName(*Entry.TargetPinName)); if (!TargetPin) { - EntryResult->SetStringField(TEXT("error"), FString::Printf(TEXT("Target pin '%s' not found on node '%s'"), *Entry.TargetPinName, *Entry.TargetNodeId)); + EntryResult->SetStringField(TEXT("error"), FString::Printf(TEXT("Target pin '%s' not found on node '%s'"), *Entry.TargetPinName, *Entry.TargetNode)); continue; } @@ -292,13 +292,13 @@ struct FDisconnectPinEntry GENERATED_BODY() UPROPERTY() - FString NodeId; + FString Node; UPROPERTY() FString PinName; UPROPERTY(meta=(Optional)) - FString TargetNodeId; + FString TargetNode; UPROPERTY(meta=(Optional)) FString TargetPinName; @@ -314,7 +314,7 @@ public: UPROPERTY(meta=(Description="Blueprint name or package path")) FString Blueprint; - UPROPERTY(meta=(Description="Array of {nodeId, pinName, targetNodeId?, targetPinName?} objects. If target is omitted, all connections on the pin are broken.")) + UPROPERTY(meta=(Description="Array of {node, pinName, targetNode?, targetPinName?} objects. If target is omitted, all connections on the pin are broken.")) FMCPJsonArray Disconnections; virtual FString GetDescription() const override @@ -350,38 +350,38 @@ public: continue; } - EntryResult->SetStringField(TEXT("nodeId"), Entry.NodeId); + EntryResult->SetStringField(TEXT("nodeId"), Entry.Node); EntryResult->SetStringField(TEXT("pinName"), Entry.PinName); - UEdGraphNode* Node = MCPUtils::FindNodeByGuid(BP, Entry.NodeId); + UEdGraphNode* Node = MCPUtils::FindNodeByGuid(BP, Entry.Node); if (!Node) { - EntryResult->SetStringField(TEXT("error"), FString::Printf(TEXT("Node '%s' not found"), *Entry.NodeId)); + EntryResult->SetStringField(TEXT("error"), FString::Printf(TEXT("Node '%s' not found"), *Entry.Node)); continue; } UEdGraphPin* Pin = Node->FindPin(FName(*Entry.PinName)); if (!Pin) { - EntryResult->SetStringField(TEXT("error"), FString::Printf(TEXT("Pin '%s' not found on node '%s'"), *Entry.PinName, *Entry.NodeId)); + EntryResult->SetStringField(TEXT("error"), FString::Printf(TEXT("Pin '%s' not found on node '%s'"), *Entry.PinName, *Entry.Node)); continue; } int32 DisconnectedCount = 0; - if (!Entry.TargetNodeId.IsEmpty() && !Entry.TargetPinName.IsEmpty()) + if (!Entry.TargetNode.IsEmpty() && !Entry.TargetPinName.IsEmpty()) { - UEdGraphNode* TargetNode = MCPUtils::FindNodeByGuid(BP, Entry.TargetNodeId); + UEdGraphNode* TargetNode = MCPUtils::FindNodeByGuid(BP, Entry.TargetNode); if (!TargetNode) { - EntryResult->SetStringField(TEXT("error"), FString::Printf(TEXT("Target node '%s' not found"), *Entry.TargetNodeId)); + EntryResult->SetStringField(TEXT("error"), FString::Printf(TEXT("Target node '%s' not found"), *Entry.TargetNode)); continue; } UEdGraphPin* TargetPin = TargetNode->FindPin(FName(*Entry.TargetPinName)); if (!TargetPin) { - EntryResult->SetStringField(TEXT("error"), FString::Printf(TEXT("Target pin '%s' not found on node '%s'"), *Entry.TargetPinName, *Entry.TargetNodeId)); + EntryResult->SetStringField(TEXT("error"), FString::Printf(TEXT("Target pin '%s' not found on node '%s'"), *Entry.TargetPinName, *Entry.TargetNode)); continue; } diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Read.cpp b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Read.cpp deleted file mode 100644 index 500d00a0..00000000 --- a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Read.cpp +++ /dev/null @@ -1,630 +0,0 @@ -#include "MCPAssetFinder.h" -#include "MCPServer.h" -#include "MCPUtils.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 -// ============================================================ - -void FBlueprintMCPServer::HandleList(const FJsonObject* Json, FJsonObject* Result) -{ - FString Filter = Json->GetStringField(TEXT("filter")); - FString ParentClassFilter = Json->GetStringField(TEXT("parentClass")); - FString TypeFilter = Json->GetStringField(TEXT("type")); - // type: "all" (default), "regular", "level" - bool bIncludeRegular = TypeFilter.IsEmpty() || TypeFilter == TEXT("all") || TypeFilter == TEXT("regular"); - bool bIncludeLevel = TypeFilter.IsEmpty() || TypeFilter == TEXT("all") || TypeFilter == TEXT("level"); - - TArray> Entries; - if (bIncludeRegular) - for (const FAssetData& Asset : UMCPAssetFinder::GetAssets(UBlueprint::StaticClass())) - { - FString Name = Asset.AssetName.ToString(); - FString Path = Asset.PackageName.ToString(); - - if (!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.IsEmpty()) - { - if (!ParentClass.Contains(ParentClassFilter, ESearchCase::IgnoreCase)) - { - continue; - } - } - - TSharedRef Entry = MakeShared(); - Entry->SetStringField(TEXT("name"), Name); - Entry->SetStringField(TEXT("path"), Path); - Entry->SetStringField(TEXT("parentClass"), ParentClass); - Entries.Add(MakeShared(Entry)); - } - - // Also include level blueprints from maps - if (bIncludeLevel) - for (const FAssetData& Asset : UMCPAssetFinder::GetAssets(UWorld::StaticClass())) - { - FString Name = Asset.AssetName.ToString(); - FString Path = Asset.PackageName.ToString(); - - if (!Filter.IsEmpty()) - { - if (!Name.Contains(Filter, ESearchCase::IgnoreCase) && - !Path.Contains(Filter, ESearchCase::IgnoreCase)) - { - continue; - } - } - - // No parent class filter for level blueprints - if (!ParentClassFilter.IsEmpty()) - { - if (!FString(TEXT("LevelScriptActor")).Contains(ParentClassFilter, ESearchCase::IgnoreCase)) - { - continue; - } - } - - TSharedRef Entry = MakeShared(); - Entry->SetStringField(TEXT("name"), Name); - Entry->SetStringField(TEXT("path"), Path); - Entry->SetStringField(TEXT("parentClass"), TEXT("LevelScriptActor")); - Entry->SetBoolField(TEXT("isLevelBlueprint"), true); - Entries.Add(MakeShared(Entry)); - } - - Result->SetNumberField(TEXT("count"), Entries.Num()); - Result->SetNumberField(TEXT("total"), UMCPAssetFinder::GetAssets(UBlueprint::StaticClass()).Num() + UMCPAssetFinder::GetAssets(UWorld::StaticClass()).Num()); - Result->SetArrayField(TEXT("blueprints"), Entries); -} - -void FBlueprintMCPServer::HandleGetBlueprint(const FJsonObject* Json, FJsonObject* Result) -{ - FString Name = Json->GetStringField(TEXT("name")); - if (Name.IsEmpty()) - { - return MCPUtils::MakeErrorJson(Result, TEXT("Missing 'name' parameter")); - } - - UBlueprint* BP = UMCPAssetFinder::LoadBlueprintOrLevelBlueprint(Name, Result); - if (!BP) return; - - TSharedRef Tmp = MCPUtils::SerializeBlueprint(BP); - MCPUtils::CopyJsonFields(&*Tmp, Result); -} - -void FBlueprintMCPServer::HandleGetGraph(const FJsonObject* Json, FJsonObject* Result) -{ - FString Name = Json->GetStringField(TEXT("name")); - FString GraphName = Json->GetStringField(TEXT("graph")); - if (Name.IsEmpty() || GraphName.IsEmpty()) - { - return MCPUtils::MakeErrorJson(Result, TEXT("Missing 'name' or 'graph' parameter")); - } - - // URL-decode graph name to handle spaces encoded as '+' or '%20' - FString DecodedGraphName = MCPUtils::UrlDecode(GraphName); - - FString LoadError; - UBlueprint* BP = UMCPAssetFinder::LoadBlueprintOrLevelBlueprint(Name, LoadError); - if (!BP) - { - return MCPUtils::MakeErrorJson(Result, LoadError); - } - - TArray AllGraphs; - BP->GetAllGraphs(AllGraphs); - - for (UEdGraph* Graph : AllGraphs) - { - if (Graph && Graph->GetName().Equals(DecodedGraphName, ESearchCase::IgnoreCase)) - { - TSharedPtr GraphJson = MCPUtils::SerializeGraph(Graph); - if (GraphJson.IsValid()) - { - MCPUtils::CopyJsonFields(GraphJson.Get(), Result); - return; - } - } - } - - // Not found — list available graphs - TArray> GraphNames; - for (UEdGraph* Graph : AllGraphs) - { - if (Graph) - { - GraphNames.Add(MakeShared(Graph->GetName())); - } - } - MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Graph '%s' not found"), *DecodedGraphName)); - Result->SetArrayField(TEXT("availableGraphs"), GraphNames); -} - -void FBlueprintMCPServer::HandleSearch(const FJsonObject* Json, FJsonObject* Result) -{ - FString Query = Json->GetStringField(TEXT("query")); - if (Query.IsEmpty()) - { - return MCPUtils::MakeErrorJson(Result, TEXT("Missing 'query' parameter")); - } - - FString PathFilter = Json->GetStringField(TEXT("path")); - - int32 MaxResults = 50; - if (Json->HasField(TEXT("maxResults"))) - { - MaxResults = FMath::Clamp(FCString::Atoi(*Json->GetStringField(TEXT("maxResults"))), 1, 200); - } - - // Build a combined list of all searchable blueprints (regular + level) - auto SearchBlueprint = [&](const FString& AssetName, const FString& Path, UBlueprint* BP, TArray>& OutResults) - { - TArray 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(Node)) - { - FuncName = CF->FunctionReference.GetMemberName().ToString(); - } - else if (auto* Ev = Cast(Node)) - { - EventName = Ev->EventReference.GetMemberName().ToString(); - } - else if (auto* CE = Cast(Node)) - { - EventName = CE->CustomFunctionName.ToString(); - } - else if (auto* VG = Cast(Node)) - { - VarName = VG->GetVarName().ToString(); - } - else if (auto* VS = Cast(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 R = MakeShared(); - 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(R)); - } - } - } - }; - - TArray> Results; - for (const FAssetData& Asset : UMCPAssetFinder::GetAssets(UBlueprint::StaticClass())) - { - if (Results.Num() >= MaxResults) break; - - FString Path = Asset.PackageName.ToString(); - if (!PathFilter.IsEmpty() && !Path.Contains(PathFilter, ESearchCase::IgnoreCase)) - { - continue; - } - - UBlueprint* BP = Cast(const_cast(Asset).GetAsset()); - if (!BP) continue; - - SearchBlueprint(Asset.AssetName.ToString(), Path, BP, Results); - } - - // Also search level blueprints - for (const FAssetData& MapAsset : UMCPAssetFinder::GetAssets(UWorld::StaticClass())) - { - if (Results.Num() >= MaxResults) break; - - FString Path = MapAsset.PackageName.ToString(); - if (!PathFilter.IsEmpty() && !Path.Contains(PathFilter, ESearchCase::IgnoreCase)) - { - continue; - } - - UWorld* World = Cast(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); - } - } - - Result->SetStringField(TEXT("query"), Query); - Result->SetNumberField(TEXT("resultCount"), Results.Num()); - Result->SetArrayField(TEXT("results"), Results); -} - -// ============================================================ -// HandleTestSave — load a Blueprint and save it unmodified (diagnostic) -// ============================================================ - -void FBlueprintMCPServer::HandleTestSave(const FJsonObject* Json, FJsonObject* Result) -{ - FString Name = Json->GetStringField(TEXT("name")); - if (Name.IsEmpty()) - { - return MCPUtils::MakeErrorJson(Result, TEXT("Missing 'name' query parameter")); - } - - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: test-save requested for '%s'"), *Name); - - FString LoadError; - UBlueprint* BP = UMCPAssetFinder::LoadBlueprintOrLevelBlueprint(Name, LoadError); - if (!BP) - { - return MCPUtils::MakeErrorJson(Result, 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 = MCPUtils::SaveBlueprintPackage(BP); - - Result->SetStringField(TEXT("blueprint"), Name); - Result->SetStringField(TEXT("packagePath"), BP->GetPackage()->GetName()); - Result->SetBoolField(TEXT("hasGeneratedClass"), BP->GeneratedClass != nullptr); - Result->SetBoolField(TEXT("saved"), bSaved); -} - -// ============================================================ -// HandleFindReferences — find all Blueprints referencing an asset -// ============================================================ - -void FBlueprintMCPServer::HandleFindReferences(const FJsonObject* Json, FJsonObject* Result) -{ - FString AssetPath = Json->GetStringField(TEXT("assetPath")); - if (AssetPath.IsEmpty()) - { - return MCPUtils::MakeErrorJson(Result, TEXT("Missing 'assetPath' query parameter")); - } - - IAssetRegistry& Registry = *IAssetRegistry::Get(); - - TArray Referencers; - Registry.GetReferencers(FName(*AssetPath), Referencers); - - // Build set of known Blueprint package names for filtering - TSet BlueprintPackages; - for (const FAssetData& Asset : UMCPAssetFinder::GetAssets(UBlueprint::StaticClass())) - { - BlueprintPackages.Add(Asset.PackageName.ToString()); - } - - TArray> BPRefs; - TArray> OtherRefs; - for (const FName& Ref : Referencers) - { - FString RefStr = Ref.ToString(); - if (BlueprintPackages.Contains(RefStr)) - { - BPRefs.Add(MakeShared(RefStr)); - } - else - { - OtherRefs.Add(MakeShared(RefStr)); - } - } - - 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); -} - -// ============================================================ -// HandleSearchByType — find all usages of a type across blueprints -// ============================================================ - -void FBlueprintMCPServer::HandleSearchByType(const FJsonObject* Json, FJsonObject* Result) -{ - FString TypeNameRaw = Json->GetStringField(TEXT("typeName")); - if (TypeNameRaw.IsEmpty()) - { - return MCPUtils::MakeErrorJson(Result, TEXT("Missing 'typeName' query parameter")); - } - - FString TypeName = MCPUtils::UrlDecode(TypeNameRaw); - FString FilterRaw = Json->GetStringField(TEXT("filter")); - FString FilterStr = FilterRaw.IsEmpty() ? FString() : MCPUtils::UrlDecode(FilterRaw); - - int32 MaxResults = 200; - if (Json->HasField(TEXT("maxResults"))) - { - MaxResults = FMath::Clamp(FCString::Atoi(*Json->GetStringField(TEXT("maxResults"))), 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> 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 R = MakeShared(); - 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(R)); - } - } - - // Check graphs for function/event params, struct nodes, and pin connections - TArray 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(Node)) - { - for (const TSharedPtr& 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 R = MakeShared(); - 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(R)); - } - } - } - else if (auto* CustomEvent = Cast(Node)) - { - for (const TSharedPtr& 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 R = MakeShared(); - 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(R)); - } - } - } - // Check Break/Make struct nodes - else if (auto* BreakNode = Cast(Node)) - { - if (BreakNode->StructType && MatchesType(BreakNode->StructType->GetName())) - { - TSharedRef R = MakeShared(); - 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(R)); - } - } - else if (auto* MakeNode = Cast(Node)) - { - if (MakeNode->StructType && MatchesType(MakeNode->StructType->GetName())) - { - TSharedRef R = MakeShared(); - 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(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 R = MakeShared(); - 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(R)); - } - } - } - } - }; - - // Search regular blueprints - for (const FAssetData& Asset : UMCPAssetFinder::GetAssets(UBlueprint::StaticClass())) - { - 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(const_cast(Asset).GetAsset()); - if (!BP) continue; - - SearchOneBlueprint(BPName, Path, BP, false); - } - - // Search level blueprints from maps - for (const FAssetData& MapAsset : UMCPAssetFinder::GetAssets(UWorld::StaticClass())) - { - 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(MapAsset.GetAsset()); - if (!World || !World->PersistentLevel) continue; - ULevelScriptBlueprint* LevelBP = World->PersistentLevel->GetLevelScriptBlueprint(false); - if (!LevelBP) continue; - - SearchOneBlueprint(MapName, Path, LevelBP, true); - } - - Result->SetStringField(TEXT("typeName"), TypeName); - Result->SetNumberField(TEXT("resultCount"), Results.Num()); - Result->SetArrayField(TEXT("results"), Results); -} diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Read.h b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Read.h new file mode 100644 index 00000000..616d3813 --- /dev/null +++ b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Read.h @@ -0,0 +1,729 @@ +#pragma once + +#include "CoreMinimal.h" +#include "MCPHandler.h" +#include "MCPAssetFinder.h" +#include "MCPUtils.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/IAssetRegistry.h" +#include "MCPHandlers_Read.generated.h" + +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + +UCLASS(meta=(ToolName="list_blueprint_assets")) +class UMCPHandler_List : public UObject, public IMCPHandler +{ + GENERATED_BODY() + +public: + UPROPERTY(meta=(Optional, Description="Substring filter for blueprint name or path")) + FString Filter; + + UPROPERTY(meta=(Optional, Description="Filter by parent class name (substring match)")) + FString ParentClass; + + UPROPERTY(meta=(Optional, Description="Type filter: 'all' (default), 'regular', or 'level'")) + FString Type; + + virtual FString GetDescription() const override + { + return TEXT("List all Blueprint assets in the project, with optional filtering by name, parent class, or type."); + } + + virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override + { + // type: "all" (default), "regular", "level" + bool bIncludeRegular = Type.IsEmpty() || Type == TEXT("all") || Type == TEXT("regular"); + bool bIncludeLevel = Type.IsEmpty() || Type == TEXT("all") || Type == TEXT("level"); + + TArray> Entries; + if (bIncludeRegular) + for (const FAssetData& Asset : UMCPAssetFinder::GetAssets(UBlueprint::StaticClass())) + { + FString Name = Asset.AssetName.ToString(); + FString Path = Asset.PackageName.ToString(); + + if (!Filter.IsEmpty()) + { + if (!Name.Contains(Filter, ESearchCase::IgnoreCase) && + !Path.Contains(Filter, ESearchCase::IgnoreCase)) + { + continue; + } + } + + FString ParentClassName; + Asset.GetTagValue(FName(TEXT("ParentClass")), ParentClassName); + // Tag stores full path — extract short name + int32 DotIndex; + if (ParentClassName.FindLastChar('.', DotIndex)) + { + ParentClassName = ParentClassName.Mid(DotIndex + 1); + } + + if (!ParentClass.IsEmpty()) + { + if (!ParentClassName.Contains(ParentClass, ESearchCase::IgnoreCase)) + { + continue; + } + } + + TSharedRef Entry = MakeShared(); + Entry->SetStringField(TEXT("name"), Name); + Entry->SetStringField(TEXT("path"), Path); + Entry->SetStringField(TEXT("parentClass"), ParentClassName); + Entries.Add(MakeShared(Entry)); + } + + // Also include level blueprints from maps + if (bIncludeLevel) + for (const FAssetData& Asset : UMCPAssetFinder::GetAssets(UWorld::StaticClass())) + { + FString Name = Asset.AssetName.ToString(); + FString Path = Asset.PackageName.ToString(); + + if (!Filter.IsEmpty()) + { + if (!Name.Contains(Filter, ESearchCase::IgnoreCase) && + !Path.Contains(Filter, ESearchCase::IgnoreCase)) + { + continue; + } + } + + // No parent class filter for level blueprints + if (!ParentClass.IsEmpty()) + { + if (!FString(TEXT("LevelScriptActor")).Contains(ParentClass, ESearchCase::IgnoreCase)) + { + continue; + } + } + + TSharedRef Entry = MakeShared(); + Entry->SetStringField(TEXT("name"), Name); + Entry->SetStringField(TEXT("path"), Path); + Entry->SetStringField(TEXT("parentClass"), TEXT("LevelScriptActor")); + Entry->SetBoolField(TEXT("isLevelBlueprint"), true); + Entries.Add(MakeShared(Entry)); + } + + Result->SetNumberField(TEXT("count"), Entries.Num()); + Result->SetNumberField(TEXT("total"), UMCPAssetFinder::GetAssets(UBlueprint::StaticClass()).Num() + UMCPAssetFinder::GetAssets(UWorld::StaticClass()).Num()); + Result->SetArrayField(TEXT("blueprints"), Entries); + } +}; + +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + +UCLASS(meta=(ToolName="dump_blueprint")) +class UMCPHandler_GetBlueprint : public UObject, public IMCPHandler +{ + GENERATED_BODY() + +public: + UPROPERTY(meta=(Description="Blueprint name or package path")) + FString Blueprint; + + virtual FString GetDescription() const override + { + return TEXT("Load and serialize a Blueprint, returning its full structure including graphs, variables, and components."); + } + + virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override + { + UBlueprint* BP = UMCPAssetFinder::LoadBlueprintOrLevelBlueprint(Blueprint, Result); + if (!BP) return; + + TSharedRef Tmp = MCPUtils::SerializeBlueprint(BP); + MCPUtils::CopyJsonFields(&*Tmp, Result); + } +}; + +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + +UCLASS(meta=(ToolName="dump_blueprint_graph")) +class UMCPHandler_GetGraph : public UObject, public IMCPHandler +{ + GENERATED_BODY() + +public: + UPROPERTY(meta=(Description="Blueprint name or package path")) + FString Blueprint; + + UPROPERTY(meta=(Description="Graph name to dump")) + FString Graph; + + virtual FString GetDescription() const override + { + return TEXT("Dump the detailed node/pin structure of a specific graph within a Blueprint."); + } + + virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override + { + // URL-decode graph name to handle spaces encoded as '+' or '%20' + FString DecodedGraphName = MCPUtils::UrlDecode(Graph); + + FString LoadError; + UBlueprint* BP = UMCPAssetFinder::LoadBlueprintOrLevelBlueprint(Blueprint, LoadError); + if (!BP) + { + return MCPUtils::MakeErrorJson(Result, LoadError); + } + + TArray AllGraphs; + BP->GetAllGraphs(AllGraphs); + + for (UEdGraph* GraphObj : AllGraphs) + { + if (GraphObj && GraphObj->GetName().Equals(DecodedGraphName, ESearchCase::IgnoreCase)) + { + TSharedPtr GraphJson = MCPUtils::SerializeGraph(GraphObj); + if (GraphJson.IsValid()) + { + MCPUtils::CopyJsonFields(GraphJson.Get(), Result); + return; + } + } + } + + // Not found — list available graphs + TArray> GraphNames; + for (UEdGraph* GraphObj : AllGraphs) + { + if (GraphObj) + { + GraphNames.Add(MakeShared(GraphObj->GetName())); + } + } + MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Graph '%s' not found"), *DecodedGraphName)); + Result->SetArrayField(TEXT("availableGraphs"), GraphNames); + } +}; + +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + +UCLASS(meta=(ToolName="search_within_blueprints")) +class UMCPHandler_Search : public UObject, public IMCPHandler +{ + GENERATED_BODY() + +public: + UPROPERTY(meta=(Description="Search query string to match against node titles, function names, event names, and variable names")) + FString Query; + + UPROPERTY(meta=(Optional, Description="Filter results to blueprints whose path contains this substring")) + FString Path; + + UPROPERTY(meta=(Optional, Description="Maximum number of results to return (default 50, max 200)")) + int32 MaxResults = 0; + + virtual FString GetDescription() const override + { + return TEXT("Search across all Blueprint graphs for nodes matching a query string."); + } + + virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override + { + int32 EffectiveMaxResults = (MaxResults > 0) ? FMath::Clamp(MaxResults, 1, 200) : 50; + + // Build a combined list of all searchable blueprints (regular + level) + auto SearchBlueprint = [&](const FString& AssetName, const FString& AssetPath, UBlueprint* BP, TArray>& OutResults) + { + TArray Graphs; + BP->GetAllGraphs(Graphs); + + for (UEdGraph* GraphObj : Graphs) + { + if (!GraphObj || OutResults.Num() >= EffectiveMaxResults) break; + + for (UEdGraphNode* Node : GraphObj->Nodes) + { + if (!Node || OutResults.Num() >= EffectiveMaxResults) break; + + FString Title = Node->GetNodeTitle(ENodeTitleType::FullTitle).ToString(); + + FString FuncName, EventName, VarName; + if (auto* CF = Cast(Node)) + { + FuncName = CF->FunctionReference.GetMemberName().ToString(); + } + else if (auto* Ev = Cast(Node)) + { + EventName = Ev->EventReference.GetMemberName().ToString(); + } + else if (auto* CE = Cast(Node)) + { + EventName = CE->CustomFunctionName.ToString(); + } + else if (auto* VG = Cast(Node)) + { + VarName = VG->GetVarName().ToString(); + } + else if (auto* VS = Cast(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 R = MakeShared(); + R->SetStringField(TEXT("blueprint"), AssetName); + R->SetStringField(TEXT("blueprintPath"), AssetPath); + R->SetStringField(TEXT("graph"), GraphObj->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(R)); + } + } + } + }; + + TArray> Results; + for (const FAssetData& Asset : UMCPAssetFinder::GetAssets(UBlueprint::StaticClass())) + { + if (Results.Num() >= EffectiveMaxResults) break; + + FString AssetPath = Asset.PackageName.ToString(); + if (!Path.IsEmpty() && !AssetPath.Contains(Path, ESearchCase::IgnoreCase)) + { + continue; + } + + UBlueprint* BP = Cast(const_cast(Asset).GetAsset()); + if (!BP) continue; + + SearchBlueprint(Asset.AssetName.ToString(), AssetPath, BP, Results); + } + + // Also search level blueprints + for (const FAssetData& MapAsset : UMCPAssetFinder::GetAssets(UWorld::StaticClass())) + { + if (Results.Num() >= EffectiveMaxResults) break; + + FString AssetPath = MapAsset.PackageName.ToString(); + if (!Path.IsEmpty() && !AssetPath.Contains(Path, ESearchCase::IgnoreCase)) + { + continue; + } + + UWorld* World = Cast(MapAsset.GetAsset()); + if (!World || !World->PersistentLevel) continue; + ULevelScriptBlueprint* LevelBP = World->PersistentLevel->GetLevelScriptBlueprint(false); + if (!LevelBP) continue; + + int32 BeforeCount = Results.Num(); + SearchBlueprint(MapAsset.AssetName.ToString(), AssetPath, 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); + } + } + + Result->SetStringField(TEXT("query"), Query); + Result->SetNumberField(TEXT("resultCount"), Results.Num()); + Result->SetArrayField(TEXT("results"), Results); + } +}; + +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + +// ============================================================ +// HandleTestSave — load a Blueprint and save it unmodified (diagnostic) +// ============================================================ + +UCLASS(meta=(ToolName="test_save_blueprint_package")) +class UMCPHandler_TestSave : public UObject, public IMCPHandler +{ + GENERATED_BODY() + +public: + UPROPERTY(meta=(Description="Blueprint name or package path")) + FString Blueprint; + + virtual FString GetDescription() const override + { + return TEXT("Load a Blueprint and save it unmodified as a diagnostic test."); + } + + virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override + { + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: test-save requested for '%s'"), *Blueprint); + + FString LoadError; + UBlueprint* BP = UMCPAssetFinder::LoadBlueprintOrLevelBlueprint(Blueprint, LoadError); + if (!BP) + { + return MCPUtils::MakeErrorJson(Result, 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 = MCPUtils::SaveBlueprintPackage(BP); + + Result->SetStringField(TEXT("blueprint"), Blueprint); + Result->SetStringField(TEXT("packagePath"), BP->GetPackage()->GetName()); + Result->SetBoolField(TEXT("hasGeneratedClass"), BP->GeneratedClass != nullptr); + Result->SetBoolField(TEXT("saved"), bSaved); + } +}; + +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + +// ============================================================ +// HandleFindReferences — find all Blueprints referencing an asset +// ============================================================ + +UCLASS(meta=(ToolName="find_asset_references")) +class UMCPHandler_FindReferences : public UObject, public IMCPHandler +{ + GENERATED_BODY() + +public: + UPROPERTY(meta=(Description="Asset package path to find references for")) + FString AssetPath; + + virtual FString GetDescription() const override + { + return TEXT("Find all assets that reference a given asset, categorized into Blueprint and non-Blueprint referencers."); + } + + virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override + { + IAssetRegistry& Registry = *IAssetRegistry::Get(); + + TArray Referencers; + Registry.GetReferencers(FName(*AssetPath), Referencers); + + // Build set of known Blueprint package names for filtering + TSet BlueprintPackages; + for (const FAssetData& Asset : UMCPAssetFinder::GetAssets(UBlueprint::StaticClass())) + { + BlueprintPackages.Add(Asset.PackageName.ToString()); + } + + TArray> BPRefs; + TArray> OtherRefs; + for (const FName& Ref : Referencers) + { + FString RefStr = Ref.ToString(); + if (BlueprintPackages.Contains(RefStr)) + { + BPRefs.Add(MakeShared(RefStr)); + } + else + { + OtherRefs.Add(MakeShared(RefStr)); + } + } + + 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); + } +}; + +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + +// ============================================================ +// HandleSearchByType — find all usages of a type across blueprints +// ============================================================ + +UCLASS(meta=(ToolName="search_type_usage_in_blueprints")) +class UMCPHandler_SearchByType : public UObject, public IMCPHandler +{ + GENERATED_BODY() + +public: + UPROPERTY(meta=(Description="Type name to search for (e.g. 'FVector', 'MyStruct'). F/E/U prefix is stripped for matching.")) + FString TypeName; + + UPROPERTY(meta=(Optional, Description="Filter to blueprints whose name or path contains this substring")) + FString Filter; + + UPROPERTY(meta=(Optional, Description="Maximum number of results to return (default 200, max 500)")) + int32 MaxResults = 0; + + virtual FString GetDescription() const override + { + return TEXT("Search all Blueprints for usages of a specific type in variables, function parameters, struct nodes, and pin connections."); + } + + virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override + { + FString DecodedTypeName = MCPUtils::UrlDecode(TypeName); + FString FilterStr = Filter.IsEmpty() ? FString() : MCPUtils::UrlDecode(Filter); + + int32 EffectiveMaxResults = (MaxResults > 0) ? FMath::Clamp(MaxResults, 1, 500) : 200; + + // Strip F/E/U prefix for comparison + FString TypeNameNoPrefix = DecodedTypeName; + if (TypeNameNoPrefix.StartsWith(TEXT("F")) || TypeNameNoPrefix.StartsWith(TEXT("E")) || TypeNameNoPrefix.StartsWith(TEXT("U"))) + { + TypeNameNoPrefix = TypeNameNoPrefix.Mid(1); + } + + auto MatchesType = [&DecodedTypeName, &TypeNameNoPrefix](const FString& TestType) -> bool + { + return TestType.Equals(DecodedTypeName, ESearchCase::IgnoreCase) || + TestType.Equals(TypeNameNoPrefix, ESearchCase::IgnoreCase); + }; + + TArray> Results; + + // Lambda that searches a single Blueprint for type usages + auto SearchOneBlueprint = [&](const FString& BPName, const FString& BPPath, UBlueprint* BP, bool bIsLevel) + { + // Check variables + for (const FBPVariableDescription& Var : BP->NewVariables) + { + if (Results.Num() >= EffectiveMaxResults) break; + + FString VarSubtype; + if (Var.VarType.PinSubCategoryObject.IsValid()) + { + VarSubtype = Var.VarType.PinSubCategoryObject->GetName(); + } + + if (MatchesType(VarSubtype) || MatchesType(Var.VarType.PinCategory.ToString())) + { + TSharedRef R = MakeShared(); + R->SetStringField(TEXT("blueprint"), BPName); + R->SetStringField(TEXT("blueprintPath"), BPPath); + 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(R)); + } + } + + // Check graphs for function/event params, struct nodes, and pin connections + TArray AllGraphs; + BP->GetAllGraphs(AllGraphs); + + for (UEdGraph* GraphObj : AllGraphs) + { + if (!GraphObj || Results.Num() >= EffectiveMaxResults) break; + + for (UEdGraphNode* Node : GraphObj->Nodes) + { + if (!Node || Results.Num() >= EffectiveMaxResults) break; + + // Check FunctionEntry/CustomEvent parameters + if (auto* FuncEntry = Cast(Node)) + { + for (const TSharedPtr& 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 R = MakeShared(); + R->SetStringField(TEXT("blueprint"), BPName); + R->SetStringField(TEXT("blueprintPath"), BPPath); + R->SetStringField(TEXT("usage"), TEXT("functionParameter")); + R->SetStringField(TEXT("location"), FString::Printf(TEXT("%s.%s"), + *GraphObj->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(R)); + } + } + } + else if (auto* CustomEvent = Cast(Node)) + { + for (const TSharedPtr& 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 R = MakeShared(); + R->SetStringField(TEXT("blueprint"), BPName); + R->SetStringField(TEXT("blueprintPath"), BPPath); + 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(R)); + } + } + } + // Check Break/Make struct nodes + else if (auto* BreakNode = Cast(Node)) + { + if (BreakNode->StructType && MatchesType(BreakNode->StructType->GetName())) + { + TSharedRef R = MakeShared(); + R->SetStringField(TEXT("blueprint"), BPName); + R->SetStringField(TEXT("blueprintPath"), BPPath); + R->SetStringField(TEXT("usage"), TEXT("breakStruct")); + R->SetStringField(TEXT("location"), GraphObj->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(R)); + } + } + else if (auto* MakeNode = Cast(Node)) + { + if (MakeNode->StructType && MatchesType(MakeNode->StructType->GetName())) + { + TSharedRef R = MakeShared(); + R->SetStringField(TEXT("blueprint"), BPName); + R->SetStringField(TEXT("blueprintPath"), BPPath); + R->SetStringField(TEXT("usage"), TEXT("makeStruct")); + R->SetStringField(TEXT("location"), GraphObj->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(R)); + } + } + + // Check pin connections carrying the type + for (UEdGraphPin* Pin : Node->Pins) + { + if (!Pin || Pin->bHidden || Results.Num() >= EffectiveMaxResults) 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 R = MakeShared(); + R->SetStringField(TEXT("blueprint"), BPName); + R->SetStringField(TEXT("blueprintPath"), BPPath); + 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"), GraphObj->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(R)); + } + } + } + } + }; + + // Search regular blueprints + for (const FAssetData& Asset : UMCPAssetFinder::GetAssets(UBlueprint::StaticClass())) + { + if (Results.Num() >= EffectiveMaxResults) break; + + FString AssetPath = Asset.PackageName.ToString(); + FString BPName = Asset.AssetName.ToString(); + + if (!FilterStr.IsEmpty() && !BPName.Contains(FilterStr, ESearchCase::IgnoreCase) && + !AssetPath.Contains(FilterStr, ESearchCase::IgnoreCase)) + { + continue; + } + + UBlueprint* BP = Cast(const_cast(Asset).GetAsset()); + if (!BP) continue; + + SearchOneBlueprint(BPName, AssetPath, BP, false); + } + + // Search level blueprints from maps + for (const FAssetData& MapAsset : UMCPAssetFinder::GetAssets(UWorld::StaticClass())) + { + if (Results.Num() >= EffectiveMaxResults) break; + + FString AssetPath = MapAsset.PackageName.ToString(); + FString MapName = MapAsset.AssetName.ToString(); + + if (!FilterStr.IsEmpty() && !MapName.Contains(FilterStr, ESearchCase::IgnoreCase) && + !AssetPath.Contains(FilterStr, ESearchCase::IgnoreCase)) + { + continue; + } + + UWorld* World = Cast(MapAsset.GetAsset()); + if (!World || !World->PersistentLevel) continue; + ULevelScriptBlueprint* LevelBP = World->PersistentLevel->GetLevelScriptBlueprint(false); + if (!LevelBP) continue; + + SearchOneBlueprint(MapName, AssetPath, LevelBP, true); + } + + Result->SetStringField(TEXT("typeName"), DecodedTypeName); + Result->SetNumberField(TEXT("resultCount"), Results.Num()); + Result->SetArrayField(TEXT("results"), Results); + } +}; diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Snapshot.cpp b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Snapshot.cpp deleted file mode 100644 index c13448ec..00000000 --- a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Snapshot.cpp +++ /dev/null @@ -1,1348 +0,0 @@ -#include "MCPAssetFinder.h" -#include "MCPServer.h" -#include "MCPUtils.h" -#include "Engine/Blueprint.h" -#include "EdGraph/EdGraph.h" -#include "EdGraph/EdGraphNode.h" -#include "EdGraph/EdGraphPin.h" -#include "EdGraphSchema_K2.h" -#include "K2Node.h" -#include "K2Node_BreakStruct.h" -#include "K2Node_MakeStruct.h" -#include "K2Node_FunctionEntry.h" -#include "K2Node_EditablePinBase.h" -#include "Kismet2/BlueprintEditorUtils.h" -#include "Serialization/JsonReader.h" -#include "Serialization/JsonWriter.h" -#include "Serialization/JsonSerializer.h" -#include "Misc/Guid.h" -#include "Misc/FileHelper.h" -#include "Misc/Paths.h" -#include "UObject/UObjectIterator.h" - -// ============================================================ -// Snapshot Helpers -// ============================================================ - -FString FBlueprintMCPServer::GenerateSnapshotId(const FString& BlueprintName) -{ - FString CleanName = BlueprintName; - CleanName.ReplaceInline(TEXT("/"), TEXT("_")); - CleanName.ReplaceInline(TEXT(" "), TEXT("_")); - FString Timestamp = FDateTime::Now().ToString(TEXT("%Y%m%d_%H%M%S")); - return FString::Printf(TEXT("%s_%s_%s"), *CleanName, *Timestamp, *FGuid::NewGuid().ToString().Left(8)); -} - -FGraphSnapshotData FBlueprintMCPServer::CaptureGraphSnapshot(UEdGraph* Graph) -{ - FGraphSnapshotData Data; - if (!Graph) return Data; - - // Record all nodes - for (UEdGraphNode* Node : Graph->Nodes) - { - if (!Node) continue; - - FNodeRecord Record; - Record.NodeGuid = Node->NodeGuid.ToString(); - Record.NodeClass = Node->GetClass()->GetName(); - Record.NodeTitle = Node->GetNodeTitle(ENodeTitleType::FullTitle).ToString(); - - // Check for Break/Make struct type - if (UK2Node_BreakStruct* BreakNode = Cast(Node)) - { - Record.StructType = BreakNode->StructType ? BreakNode->StructType->GetName() : TEXT(""); - } - else if (UK2Node_MakeStruct* MakeNode = Cast(Node)) - { - Record.StructType = MakeNode->StructType ? MakeNode->StructType->GetName() : TEXT(""); - } - - Data.Nodes.Add(Record); - - // Record ALL pin connections (only from output pins to avoid duplicates) - for (UEdGraphPin* Pin : Node->Pins) - { - if (!Pin) continue; - if (Pin->Direction != EGPD_Output) continue; - - for (UEdGraphPin* Linked : Pin->LinkedTo) - { - if (!Linked || !Linked->GetOwningNode()) continue; - - FPinConnectionRecord ConnRecord; - ConnRecord.SourceNodeGuid = Node->NodeGuid.ToString(); - ConnRecord.SourcePinName = Pin->PinName.ToString(); - ConnRecord.TargetNodeGuid = Linked->GetOwningNode()->NodeGuid.ToString(); - ConnRecord.TargetPinName = Linked->PinName.ToString(); - Data.Connections.Add(ConnRecord); - } - } - } - - return Data; -} - -void FBlueprintMCPServer::PruneOldSnapshots() -{ - while (Snapshots.Num() > MaxSnapshots) - { - FString OldestId; - FDateTime OldestTime = FDateTime::MaxValue(); - - for (const auto& Pair : Snapshots) - { - if (Pair.Value.CreatedAt < OldestTime) - { - OldestTime = Pair.Value.CreatedAt; - OldestId = Pair.Key; - } - } - - if (!OldestId.IsEmpty()) - { - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Pruning old snapshot '%s'"), *OldestId); - Snapshots.Remove(OldestId); - } - else - { - break; - } - } -} - -bool FBlueprintMCPServer::SaveSnapshotToDisk(const FString& SnapshotId, const FGraphSnapshot& Snapshot) -{ - FString Dir = FPaths::ProjectSavedDir() / TEXT("BlueprintMCP") / TEXT("Snapshots"); - IFileManager::Get().MakeDirectory(*Dir, true); - - FString FilePath = Dir / (SnapshotId + TEXT(".json")); - - // Serialize to JSON - TSharedRef Root = MakeShared(); - Root->SetStringField(TEXT("snapshotId"), Snapshot.SnapshotId); - Root->SetStringField(TEXT("blueprintName"), Snapshot.BlueprintName); - Root->SetStringField(TEXT("blueprintPath"), Snapshot.BlueprintPath); - Root->SetStringField(TEXT("createdAt"), Snapshot.CreatedAt.ToIso8601()); - - TSharedRef GraphsObj = MakeShared(); - for (const auto& GraphPair : Snapshot.Graphs) - { - TSharedRef GraphObj = MakeShared(); - - // Nodes - TArray> NodesArr; - for (const FNodeRecord& NodeRec : GraphPair.Value.Nodes) - { - TSharedRef NJ = MakeShared(); - NJ->SetStringField(TEXT("nodeGuid"), NodeRec.NodeGuid); - NJ->SetStringField(TEXT("nodeClass"), NodeRec.NodeClass); - NJ->SetStringField(TEXT("nodeTitle"), NodeRec.NodeTitle); - if (!NodeRec.StructType.IsEmpty()) - { - NJ->SetStringField(TEXT("structType"), NodeRec.StructType); - } - NodesArr.Add(MakeShared(NJ)); - } - GraphObj->SetArrayField(TEXT("nodes"), NodesArr); - - // Connections - TArray> ConnsArr; - for (const FPinConnectionRecord& ConnRec : GraphPair.Value.Connections) - { - TSharedRef CJ = MakeShared(); - CJ->SetStringField(TEXT("sourceNodeGuid"), ConnRec.SourceNodeGuid); - CJ->SetStringField(TEXT("sourcePinName"), ConnRec.SourcePinName); - CJ->SetStringField(TEXT("targetNodeGuid"), ConnRec.TargetNodeGuid); - CJ->SetStringField(TEXT("targetPinName"), ConnRec.TargetPinName); - ConnsArr.Add(MakeShared(CJ)); - } - GraphObj->SetArrayField(TEXT("connections"), ConnsArr); - - GraphsObj->SetObjectField(GraphPair.Key, GraphObj); - } - Root->SetObjectField(TEXT("graphs"), GraphsObj); - - FString JsonString = MCPUtils::JsonToString(Root); - bool bSuccess = FFileHelper::SaveStringToFile(JsonString, *FilePath, FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM); - if (bSuccess) - { - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Saved snapshot to disk: %s"), *FilePath); - } - else - { - UE_LOG(LogTemp, Warning, TEXT("BlueprintMCP: Failed to save snapshot to disk: %s"), *FilePath); - } - return bSuccess; -} - -bool FBlueprintMCPServer::LoadSnapshotFromDisk(const FString& SnapshotId, FGraphSnapshot& OutSnapshot) -{ - FString Dir = FPaths::ProjectSavedDir() / TEXT("BlueprintMCP") / TEXT("Snapshots"); - FString FilePath = Dir / (SnapshotId + TEXT(".json")); - - FString JsonString; - if (!FFileHelper::LoadFileToString(JsonString, *FilePath)) - { - return false; - } - - TSharedPtr Root; - TSharedRef> Reader = TJsonReaderFactory<>::Create(JsonString); - if (!FJsonSerializer::Deserialize(Reader, Root) || !Root.IsValid()) - { - return false; - } - - OutSnapshot.SnapshotId = Root->GetStringField(TEXT("snapshotId")); - OutSnapshot.BlueprintName = Root->GetStringField(TEXT("blueprintName")); - OutSnapshot.BlueprintPath = Root->GetStringField(TEXT("blueprintPath")); - FDateTime::ParseIso8601(*Root->GetStringField(TEXT("createdAt")), OutSnapshot.CreatedAt); - - const TSharedPtr* GraphsObjPtr = nullptr; - if (Root->TryGetObjectField(TEXT("graphs"), GraphsObjPtr) && GraphsObjPtr && (*GraphsObjPtr).IsValid()) - { - for (const auto& GraphPair : (*GraphsObjPtr)->Values) - { - FGraphSnapshotData GraphData; - const TSharedPtr& GraphObj = GraphPair.Value->AsObject(); - if (!GraphObj.IsValid()) continue; - - // Nodes - const TArray>* NodesArrPtr = nullptr; - if (GraphObj->TryGetArrayField(TEXT("nodes"), NodesArrPtr)) - { - for (const TSharedPtr& NodeVal : *NodesArrPtr) - { - const TSharedPtr& NJ = NodeVal->AsObject(); - if (!NJ.IsValid()) continue; - - FNodeRecord NodeRec; - NodeRec.NodeGuid = NJ->GetStringField(TEXT("nodeGuid")); - NodeRec.NodeClass = NJ->GetStringField(TEXT("nodeClass")); - NodeRec.NodeTitle = NJ->GetStringField(TEXT("nodeTitle")); - NJ->TryGetStringField(TEXT("structType"), NodeRec.StructType); - GraphData.Nodes.Add(NodeRec); - } - } - - // Connections - const TArray>* ConnsArrPtr = nullptr; - if (GraphObj->TryGetArrayField(TEXT("connections"), ConnsArrPtr)) - { - for (const TSharedPtr& ConnVal : *ConnsArrPtr) - { - const TSharedPtr& CJ = ConnVal->AsObject(); - if (!CJ.IsValid()) continue; - - FPinConnectionRecord ConnRec; - ConnRec.SourceNodeGuid = CJ->GetStringField(TEXT("sourceNodeGuid")); - ConnRec.SourcePinName = CJ->GetStringField(TEXT("sourcePinName")); - ConnRec.TargetNodeGuid = CJ->GetStringField(TEXT("targetNodeGuid")); - ConnRec.TargetPinName = CJ->GetStringField(TEXT("targetPinName")); - GraphData.Connections.Add(ConnRec); - } - } - - OutSnapshot.Graphs.Add(GraphPair.Key, GraphData); - } - } - - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Loaded snapshot from disk: %s"), *FilePath); - return true; -} - -// ============================================================ -// HandleSnapshotGraph -// ============================================================ - -void FBlueprintMCPServer::HandleSnapshotGraph(const FJsonObject* Json, FJsonObject* Result) -{ - FString BlueprintName = Json->GetStringField(TEXT("blueprint")); - if (BlueprintName.IsEmpty()) - { - return MCPUtils::MakeErrorJson(Result, TEXT("Missing required field: blueprint")); - } - - FString GraphFilter; - Json->TryGetStringField(TEXT("graph"), GraphFilter); - - // Load Blueprint - FString LoadError; - UBlueprint* BP = UMCPAssetFinder::LoadBlueprintOrLevelBlueprint(BlueprintName, LoadError); - if (!BP) - { - return MCPUtils::MakeErrorJson(Result, LoadError); - } - - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Creating snapshot for blueprint '%s'"), *BlueprintName); - - // Build the snapshot - FGraphSnapshot Snapshot; - Snapshot.SnapshotId = GenerateSnapshotId(BlueprintName); - Snapshot.BlueprintName = BP->GetName(); - Snapshot.BlueprintPath = BP->GetPathName(); - Snapshot.CreatedAt = FDateTime::Now(); - - // Gather all graphs (UbergraphPages + FunctionGraphs) - TArray GraphsToCapture; - for (UEdGraph* Graph : BP->UbergraphPages) - { - if (!Graph) continue; - if (!GraphFilter.IsEmpty() && Graph->GetName() != GraphFilter) continue; - GraphsToCapture.Add(Graph); - } - for (UEdGraph* Graph : BP->FunctionGraphs) - { - if (!Graph) continue; - if (!GraphFilter.IsEmpty() && Graph->GetName() != GraphFilter) continue; - GraphsToCapture.Add(Graph); - } - - if (GraphsToCapture.Num() == 0 && !GraphFilter.IsEmpty()) - { - return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Graph '%s' not found in blueprint '%s'"), *GraphFilter, *BlueprintName)); - } - - int32 TotalConnections = 0; - TArray> GraphSummaries; - - for (UEdGraph* Graph : GraphsToCapture) - { - FGraphSnapshotData GraphData = CaptureGraphSnapshot(Graph); - - TSharedRef Summary = MakeShared(); - Summary->SetStringField(TEXT("name"), Graph->GetName()); - Summary->SetNumberField(TEXT("nodeCount"), GraphData.Nodes.Num()); - Summary->SetNumberField(TEXT("connectionCount"), GraphData.Connections.Num()); - GraphSummaries.Add(MakeShared(Summary)); - - TotalConnections += GraphData.Connections.Num(); - Snapshot.Graphs.Add(Graph->GetName(), MoveTemp(GraphData)); - } - - // Store in memory - Snapshots.Add(Snapshot.SnapshotId, Snapshot); - PruneOldSnapshots(); - - // Save to disk - SaveSnapshotToDisk(Snapshot.SnapshotId, Snapshot); - - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Snapshot '%s' created with %d graphs, %d total connections"), - *Snapshot.SnapshotId, GraphsToCapture.Num(), TotalConnections); - - // Build response - Result->SetStringField(TEXT("status"), TEXT("ok")); - Result->SetStringField(TEXT("snapshotId"), Snapshot.SnapshotId); - Result->SetStringField(TEXT("blueprint"), BP->GetName()); - Result->SetArrayField(TEXT("graphs"), GraphSummaries); - Result->SetNumberField(TEXT("totalConnections"), TotalConnections); -} - -// ============================================================ -// HandleDiffGraph -// ============================================================ - -void FBlueprintMCPServer::HandleDiffGraph(const FJsonObject* Json, FJsonObject* Result) -{ - FString BlueprintName = Json->GetStringField(TEXT("blueprint")); - FString SnapshotId = Json->GetStringField(TEXT("snapshotId")); - if (BlueprintName.IsEmpty() || SnapshotId.IsEmpty()) - { - return MCPUtils::MakeErrorJson(Result, TEXT("Missing required fields: blueprint, snapshotId")); - } - - FString GraphFilter; - Json->TryGetStringField(TEXT("graph"), GraphFilter); - - // Load snapshot from memory or disk - FGraphSnapshot* SnapshotPtr = Snapshots.Find(SnapshotId); - FGraphSnapshot LoadedSnapshot; - if (!SnapshotPtr) - { - if (!LoadSnapshotFromDisk(SnapshotId, LoadedSnapshot)) - { - return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Snapshot '%s' not found in memory or on disk"), *SnapshotId)); - } - SnapshotPtr = &LoadedSnapshot; - } - - // Load the current blueprint - FString LoadError; - UBlueprint* BP = UMCPAssetFinder::LoadBlueprintOrLevelBlueprint(BlueprintName, LoadError); - if (!BP) - { - return MCPUtils::MakeErrorJson(Result, LoadError); - } - - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Diffing blueprint '%s' against snapshot '%s'"), *BlueprintName, *SnapshotId); - - // Build current state for comparison - TMap CurrentGraphs; - TArray AllGraphs; - for (UEdGraph* Graph : BP->UbergraphPages) - { - if (Graph) AllGraphs.Add(Graph); - } - for (UEdGraph* Graph : BP->FunctionGraphs) - { - if (Graph) AllGraphs.Add(Graph); - } - for (UEdGraph* Graph : AllGraphs) - { - if (!GraphFilter.IsEmpty() && Graph->GetName() != GraphFilter) continue; - CurrentGraphs.Add(Graph->GetName(), CaptureGraphSnapshot(Graph)); - } - - // Build lookup maps for current state - // Key: "GraphName|SourceGuid|SourcePin|TargetGuid|TargetPin" - auto MakeConnKey = [](const FString& SrcGuid, const FString& SrcPin, const FString& TgtGuid, const FString& TgtPin) -> FString - { - return FString::Printf(TEXT("%s|%s|%s|%s"), *SrcGuid, *SrcPin, *TgtGuid, *TgtPin); - }; - - // Build node lookup maps: GUID -> NodeRecord - TMap SnapshotNodeMap; - TMap CurrentNodeMap; - - TArray> SeveredArr; - TArray> NewConnsArr; - TArray> TypeChangesArr; - TArray> MissingNodesArr; - - // Process each graph in the snapshot - for (const auto& SnapGraphPair : SnapshotPtr->Graphs) - { - const FString& GraphName = SnapGraphPair.Key; - if (!GraphFilter.IsEmpty() && GraphName != GraphFilter) continue; - - const FGraphSnapshotData& SnapData = SnapGraphPair.Value; - const FGraphSnapshotData* CurDataPtr = CurrentGraphs.Find(GraphName); - - // Build snapshot node map for this graph - TMap SnapNodeLookup; - for (const FNodeRecord& NR : SnapData.Nodes) - { - SnapNodeLookup.Add(NR.NodeGuid, &NR); - } - - // Build current connection set and node map for this graph - TSet CurrentConnSet; - TMap CurNodeLookup; - if (CurDataPtr) - { - for (const FNodeRecord& NR : CurDataPtr->Nodes) - { - CurNodeLookup.Add(NR.NodeGuid, &NR); - } - for (const FPinConnectionRecord& Conn : CurDataPtr->Connections) - { - CurrentConnSet.Add(MakeConnKey(Conn.SourceNodeGuid, Conn.SourcePinName, Conn.TargetNodeGuid, Conn.TargetPinName)); - } - } - - // Build snapshot connection set - TSet SnapConnSet; - for (const FPinConnectionRecord& Conn : SnapData.Connections) - { - SnapConnSet.Add(MakeConnKey(Conn.SourceNodeGuid, Conn.SourcePinName, Conn.TargetNodeGuid, Conn.TargetPinName)); - } - - // Find severed connections: in snapshot but not in current - for (const FPinConnectionRecord& Conn : SnapData.Connections) - { - FString Key = MakeConnKey(Conn.SourceNodeGuid, Conn.SourcePinName, Conn.TargetNodeGuid, Conn.TargetPinName); - if (!CurrentConnSet.Contains(Key)) - { - TSharedRef SJ = MakeShared(); - SJ->SetStringField(TEXT("graph"), GraphName); - SJ->SetStringField(TEXT("sourceNodeGuid"), Conn.SourceNodeGuid); - SJ->SetStringField(TEXT("sourcePinName"), Conn.SourcePinName); - SJ->SetStringField(TEXT("targetNodeGuid"), Conn.TargetNodeGuid); - SJ->SetStringField(TEXT("targetPinName"), Conn.TargetPinName); - - // Add node names for readability - const FNodeRecord** SrcRec = SnapNodeLookup.Find(Conn.SourceNodeGuid); - if (SrcRec) SJ->SetStringField(TEXT("sourceNodeName"), (*SrcRec)->NodeTitle); - const FNodeRecord** TgtRec = SnapNodeLookup.Find(Conn.TargetNodeGuid); - if (TgtRec) SJ->SetStringField(TEXT("targetNodeName"), (*TgtRec)->NodeTitle); - - SeveredArr.Add(MakeShared(SJ)); - } - } - - // Find new connections: in current but not in snapshot - if (CurDataPtr) - { - for (const FPinConnectionRecord& Conn : CurDataPtr->Connections) - { - FString Key = MakeConnKey(Conn.SourceNodeGuid, Conn.SourcePinName, Conn.TargetNodeGuid, Conn.TargetPinName); - if (!SnapConnSet.Contains(Key)) - { - TSharedRef NJ = MakeShared(); - NJ->SetStringField(TEXT("graph"), GraphName); - NJ->SetStringField(TEXT("sourceNodeGuid"), Conn.SourceNodeGuid); - NJ->SetStringField(TEXT("sourcePinName"), Conn.SourcePinName); - NJ->SetStringField(TEXT("targetNodeGuid"), Conn.TargetNodeGuid); - NJ->SetStringField(TEXT("targetPinName"), Conn.TargetPinName); - - const FNodeRecord** SrcRec = CurNodeLookup.Find(Conn.SourceNodeGuid); - if (SrcRec) NJ->SetStringField(TEXT("sourceNodeName"), (*SrcRec)->NodeTitle); - const FNodeRecord** TgtRec = CurNodeLookup.Find(Conn.TargetNodeGuid); - if (TgtRec) NJ->SetStringField(TEXT("targetNodeName"), (*TgtRec)->NodeTitle); - - NewConnsArr.Add(MakeShared(NJ)); - } - } - } - - // Find type changes and missing nodes - for (const FNodeRecord& SnapNode : SnapData.Nodes) - { - const FNodeRecord** CurNodePtr = CurNodeLookup.Find(SnapNode.NodeGuid); - if (!CurNodePtr) - { - // Missing node - TSharedRef MJ = MakeShared(); - MJ->SetStringField(TEXT("graph"), GraphName); - MJ->SetStringField(TEXT("nodeGuid"), SnapNode.NodeGuid); - MJ->SetStringField(TEXT("nodeClass"), SnapNode.NodeClass); - MJ->SetStringField(TEXT("nodeTitle"), SnapNode.NodeTitle); - if (!SnapNode.StructType.IsEmpty()) - { - MJ->SetStringField(TEXT("structType"), SnapNode.StructType); - } - MissingNodesArr.Add(MakeShared(MJ)); - } - else if (!SnapNode.StructType.IsEmpty()) - { - // Check for type change on Break/Make nodes - const FNodeRecord* CurNode = *CurNodePtr; - if (CurNode->StructType != SnapNode.StructType) - { - TSharedRef TJ = MakeShared(); - TJ->SetStringField(TEXT("graph"), GraphName); - TJ->SetStringField(TEXT("nodeGuid"), SnapNode.NodeGuid); - TJ->SetStringField(TEXT("nodeTitle"), SnapNode.NodeTitle); - TJ->SetStringField(TEXT("oldStructType"), SnapNode.StructType); - TJ->SetStringField(TEXT("newStructType"), CurNode->StructType); - TypeChangesArr.Add(MakeShared(TJ)); - } - } - } - } - - // Build result - Result->SetStringField(TEXT("status"), TEXT("ok")); - Result->SetStringField(TEXT("blueprint"), BP->GetName()); - Result->SetStringField(TEXT("snapshotId"), SnapshotId); - Result->SetArrayField(TEXT("severedConnections"), SeveredArr); - Result->SetArrayField(TEXT("newConnections"), NewConnsArr); - Result->SetArrayField(TEXT("typeChanges"), TypeChangesArr); - Result->SetArrayField(TEXT("missingNodes"), MissingNodesArr); - - TSharedRef Summary = MakeShared(); - Summary->SetNumberField(TEXT("severedConnections"), SeveredArr.Num()); - Summary->SetNumberField(TEXT("newConnections"), NewConnsArr.Num()); - Summary->SetNumberField(TEXT("typeChanges"), TypeChangesArr.Num()); - Summary->SetNumberField(TEXT("missingNodes"), MissingNodesArr.Num()); - Result->SetObjectField(TEXT("summary"), Summary); - - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Diff complete — %d severed, %d new, %d type changes, %d missing nodes"), - SeveredArr.Num(), NewConnsArr.Num(), TypeChangesArr.Num(), MissingNodesArr.Num()); -} - -// ============================================================ -// HandleRestoreGraph -// ============================================================ - -void FBlueprintMCPServer::HandleRestoreGraph(const FJsonObject* Json, FJsonObject* Result) -{ - FString BlueprintName = Json->GetStringField(TEXT("blueprint")); - FString SnapshotId = Json->GetStringField(TEXT("snapshotId")); - if (BlueprintName.IsEmpty() || SnapshotId.IsEmpty()) - { - return MCPUtils::MakeErrorJson(Result, TEXT("Missing required fields: blueprint, snapshotId")); - } - - FString GraphFilter; - Json->TryGetStringField(TEXT("graph"), GraphFilter); - - FString NodeIdFilter; - Json->TryGetStringField(TEXT("nodeId"), NodeIdFilter); - - bool bDryRun = false; - Json->TryGetBoolField(TEXT("dryRun"), bDryRun); - - // Load snapshot from memory or disk - FGraphSnapshot* SnapshotPtr = Snapshots.Find(SnapshotId); - FGraphSnapshot LoadedSnapshot; - if (!SnapshotPtr) - { - if (!LoadSnapshotFromDisk(SnapshotId, LoadedSnapshot)) - { - return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Snapshot '%s' not found in memory or on disk"), *SnapshotId)); - } - SnapshotPtr = &LoadedSnapshot; - } - - // Load the current blueprint - FString LoadError; - UBlueprint* BP = UMCPAssetFinder::LoadBlueprintOrLevelBlueprint(BlueprintName, LoadError); - if (!BP) - { - return MCPUtils::MakeErrorJson(Result, LoadError); - } - - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Restoring connections from snapshot '%s' for blueprint '%s' (dryRun=%s)"), - *SnapshotId, *BlueprintName, bDryRun ? TEXT("true") : TEXT("false")); - - // Build current connection set for comparison - TMap CurrentGraphs; - TArray AllGraphs; - for (UEdGraph* Graph : BP->UbergraphPages) - { - if (Graph) AllGraphs.Add(Graph); - } - for (UEdGraph* Graph : BP->FunctionGraphs) - { - if (Graph) AllGraphs.Add(Graph); - } - for (UEdGraph* Graph : AllGraphs) - { - CurrentGraphs.Add(Graph->GetName(), CaptureGraphSnapshot(Graph)); - } - - auto MakeConnKey = [](const FString& SrcGuid, const FString& SrcPin, const FString& TgtGuid, const FString& TgtPin) -> FString - { - return FString::Printf(TEXT("%s|%s|%s|%s"), *SrcGuid, *SrcPin, *TgtGuid, *TgtPin); - }; - - int32 Reconnected = 0; - int32 Failed = 0; - TArray> DetailsArr; - - for (const auto& SnapGraphPair : SnapshotPtr->Graphs) - { - const FString& GraphName = SnapGraphPair.Key; - if (!GraphFilter.IsEmpty() && GraphName != GraphFilter) continue; - - const FGraphSnapshotData& SnapData = SnapGraphPair.Value; - const FGraphSnapshotData* CurDataPtr = CurrentGraphs.Find(GraphName); - - // Build current connection set - TSet CurrentConnSet; - if (CurDataPtr) - { - for (const FPinConnectionRecord& Conn : CurDataPtr->Connections) - { - CurrentConnSet.Add(MakeConnKey(Conn.SourceNodeGuid, Conn.SourcePinName, Conn.TargetNodeGuid, Conn.TargetPinName)); - } - } - - // Find severed connections and try to restore them - for (const FPinConnectionRecord& Conn : SnapData.Connections) - { - FString Key = MakeConnKey(Conn.SourceNodeGuid, Conn.SourcePinName, Conn.TargetNodeGuid, Conn.TargetPinName); - if (CurrentConnSet.Contains(Key)) continue; // Still connected, skip - - // Apply nodeId filter if specified - if (!NodeIdFilter.IsEmpty() && Conn.SourceNodeGuid != NodeIdFilter && Conn.TargetNodeGuid != NodeIdFilter) - { - continue; - } - - TSharedRef Detail = MakeShared(); - Detail->SetStringField(TEXT("graph"), GraphName); - Detail->SetStringField(TEXT("sourcePinName"), Conn.SourcePinName); - Detail->SetStringField(TEXT("targetPinName"), Conn.TargetPinName); - Detail->SetStringField(TEXT("sourceNodeGuid"), Conn.SourceNodeGuid); - Detail->SetStringField(TEXT("targetNodeGuid"), Conn.TargetNodeGuid); - - // Find source and target nodes - UEdGraph* SourceGraph = nullptr; - UEdGraphNode* SourceNode = MCPUtils::FindNodeByGuid(BP, Conn.SourceNodeGuid, &SourceGraph); - UEdGraphNode* TargetNode = MCPUtils::FindNodeByGuid(BP, Conn.TargetNodeGuid); - - if (!SourceNode) - { - Detail->SetStringField(TEXT("result"), TEXT("failed")); - Detail->SetStringField(TEXT("reason"), FString::Printf(TEXT("Source node '%s' no longer exists"), *Conn.SourceNodeGuid)); - Failed++; - DetailsArr.Add(MakeShared(Detail)); - continue; - } - if (!TargetNode) - { - Detail->SetStringField(TEXT("result"), TEXT("failed")); - Detail->SetStringField(TEXT("reason"), FString::Printf(TEXT("Target node '%s' no longer exists"), *Conn.TargetNodeGuid)); - Failed++; - DetailsArr.Add(MakeShared(Detail)); - continue; - } - - Detail->SetStringField(TEXT("sourceNodeName"), SourceNode->GetNodeTitle(ENodeTitleType::FullTitle).ToString()); - Detail->SetStringField(TEXT("targetNodeName"), TargetNode->GetNodeTitle(ENodeTitleType::FullTitle).ToString()); - - // Find pins - UEdGraphPin* SourcePin = SourceNode->FindPin(FName(*Conn.SourcePinName)); - UEdGraphPin* TargetPin = TargetNode->FindPin(FName(*Conn.TargetPinName)); - - if (!SourcePin) - { - Detail->SetStringField(TEXT("result"), TEXT("failed")); - Detail->SetStringField(TEXT("reason"), FString::Printf(TEXT("Source pin '%s' not found on node"), *Conn.SourcePinName)); - Failed++; - DetailsArr.Add(MakeShared(Detail)); - continue; - } - if (!TargetPin) - { - Detail->SetStringField(TEXT("result"), TEXT("failed")); - Detail->SetStringField(TEXT("reason"), FString::Printf(TEXT("Target pin '%s' not found on node"), *Conn.TargetPinName)); - Failed++; - DetailsArr.Add(MakeShared(Detail)); - continue; - } - - if (bDryRun) - { - // In dry run, just report what would happen - Detail->SetStringField(TEXT("result"), TEXT("would_reconnect")); - Reconnected++; - DetailsArr.Add(MakeShared(Detail)); - continue; - } - - // Try type-validated connection via the schema - const UEdGraphSchema* Schema = SourceGraph ? SourceGraph->GetSchema() : nullptr; - if (!Schema) - { - Detail->SetStringField(TEXT("result"), TEXT("failed")); - Detail->SetStringField(TEXT("reason"), TEXT("Graph schema not found")); - Failed++; - DetailsArr.Add(MakeShared(Detail)); - continue; - } - - bool bConnected = Schema->TryCreateConnection(SourcePin, TargetPin); - if (bConnected) - { - Detail->SetStringField(TEXT("result"), TEXT("reconnected")); - Reconnected++; - } - else - { - Detail->SetStringField(TEXT("result"), TEXT("failed")); - Detail->SetStringField(TEXT("reason"), FString::Printf( - TEXT("TryCreateConnection failed — types may be incompatible (%s -> %s)"), - *SourcePin->PinType.PinCategory.ToString(), - *TargetPin->PinType.PinCategory.ToString())); - Failed++; - } - DetailsArr.Add(MakeShared(Detail)); - } - } - - // Save if not dry run and we reconnected something - bool bSaved = false; - if (!bDryRun && Reconnected > 0) - { - bSaved = MCPUtils::SaveBlueprintPackage(BP); - } - - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Restore complete — %d reconnected, %d failed, saved=%s"), - Reconnected, Failed, bSaved ? TEXT("true") : TEXT("false")); - - Result->SetStringField(TEXT("status"), TEXT("ok")); - Result->SetNumberField(TEXT("reconnected"), Reconnected); - Result->SetNumberField(TEXT("failed"), Failed); - Result->SetArrayField(TEXT("details"), DetailsArr); - Result->SetBoolField(TEXT("saved"), bSaved); - Result->SetBoolField(TEXT("dryRun"), bDryRun); -} - -// ============================================================ -// HandleFindDisconnectedPins -// ============================================================ - -void FBlueprintMCPServer::HandleFindDisconnectedPins(const FJsonObject* Json, FJsonObject* Result) -{ - FString BlueprintName; - Json->TryGetStringField(TEXT("blueprint"), BlueprintName); - - FString PathFilter; - Json->TryGetStringField(TEXT("filter"), PathFilter); - - FString SnapshotId; - Json->TryGetStringField(TEXT("snapshotId"), SnapshotId); - - if (BlueprintName.IsEmpty() && PathFilter.IsEmpty() && SnapshotId.IsEmpty()) - { - return MCPUtils::MakeErrorJson(Result, TEXT("Provide at least one of: blueprint, filter, or snapshotId")); - } - - // Optionally load snapshot for definite-break detection - FGraphSnapshot* SnapshotPtr = nullptr; - FGraphSnapshot LoadedSnapshot; - if (!SnapshotId.IsEmpty()) - { - SnapshotPtr = Snapshots.Find(SnapshotId); - if (!SnapshotPtr) - { - if (LoadSnapshotFromDisk(SnapshotId, LoadedSnapshot)) - { - SnapshotPtr = &LoadedSnapshot; - } - } - } - - // Build snapshot connection lookup: "nodeGuid|pinName" -> array of connected targets - TMap> SnapConnByNode; - if (SnapshotPtr) - { - for (const auto& GraphPair : SnapshotPtr->Graphs) - { - for (const FPinConnectionRecord& Conn : GraphPair.Value.Connections) - { - FString SrcKey = FString::Printf(TEXT("%s|%s"), *Conn.SourceNodeGuid, *Conn.SourcePinName); - SnapConnByNode.FindOrAdd(SrcKey).Add(Conn); - FString TgtKey = FString::Printf(TEXT("%s|%s"), *Conn.TargetNodeGuid, *Conn.TargetPinName); - SnapConnByNode.FindOrAdd(TgtKey).Add(Conn); - } - } - } - - // Collect blueprints to scan - TArray BlueprintsToScan; - if (!BlueprintName.IsEmpty()) - { - BlueprintsToScan.Add(BlueprintName); - } - else if (!PathFilter.IsEmpty()) - { - for (const FAssetData& Asset : UMCPAssetFinder::GetAssets(UBlueprint::StaticClass())) - { - if (Asset.PackageName.ToString().Contains(PathFilter) || Asset.AssetName.ToString().Contains(PathFilter)) - { - BlueprintsToScan.Add(Asset.AssetName.ToString()); - } - } - } - else if (SnapshotPtr) - { - // Use the snapshot's blueprint - BlueprintsToScan.Add(SnapshotPtr->BlueprintName); - } - - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Finding disconnected pins across %d blueprint(s)"), BlueprintsToScan.Num()); - - TArray> ResultsArr; - int32 HighCount = 0; - int32 MediumCount = 0; - int32 BlueprintsScanned = 0; - - for (const FString& BPName : BlueprintsToScan) - { - FString LoadError; - UBlueprint* BP = UMCPAssetFinder::LoadBlueprintOrLevelBlueprint(BPName, LoadError); - if (!BP) continue; - BlueprintsScanned++; - - TArray AllGraphs; - for (UEdGraph* Graph : BP->UbergraphPages) - { - if (Graph) AllGraphs.Add(Graph); - } - for (UEdGraph* Graph : BP->FunctionGraphs) - { - if (Graph) AllGraphs.Add(Graph); - } - - for (UEdGraph* Graph : AllGraphs) - { - for (UEdGraphNode* Node : Graph->Nodes) - { - if (!Node) continue; - - UK2Node_BreakStruct* BreakNode = Cast(Node); - UK2Node_MakeStruct* MakeNode = Cast(Node); - - // Only inspect Break/Make struct nodes for heuristic detection - bool bIsBreakMakeNode = (BreakNode || MakeNode); - - FString StructTypeName; - if (BreakNode) - { - StructTypeName = BreakNode->StructType ? BreakNode->StructType->GetName() : TEXT(""); - } - else if (MakeNode) - { - StructTypeName = MakeNode->StructType ? MakeNode->StructType->GetName() : TEXT(""); - } - - // Heuristic detection for Break/Make nodes - if (bIsBreakMakeNode) - { - bool bUnresolvedStruct = StructTypeName.Contains(TEXT("unknown")) || - StructTypeName.Equals(TEXT("None")) || StructTypeName.IsEmpty(); - - if (bUnresolvedStruct) - { - // HIGH confidence: unresolved struct - TSharedRef Item = MakeShared(); - Item->SetStringField(TEXT("blueprint"), BP->GetName()); - Item->SetStringField(TEXT("graph"), Graph->GetName()); - Item->SetStringField(TEXT("nodeId"), Node->NodeGuid.ToString()); - Item->SetStringField(TEXT("nodeTitle"), Node->GetNodeTitle(ENodeTitleType::FullTitle).ToString()); - Item->SetStringField(TEXT("structType"), StructTypeName); - Item->SetStringField(TEXT("confidence"), TEXT("HIGH")); - Item->SetStringField(TEXT("reason"), TEXT("Unresolved or unknown struct type")); - - // List pins - TArray> PinsArr; - for (UEdGraphPin* Pin : Node->Pins) - { - if (!Pin || Pin->bHidden) continue; - if (Pin->PinType.PinCategory == UEdGraphSchema_K2::PC_Exec) continue; - TSharedRef PinJ = MakeShared(); - PinJ->SetStringField(TEXT("name"), Pin->PinName.ToString()); - PinJ->SetStringField(TEXT("type"), Pin->PinType.PinCategory.ToString()); - - // Check snapshot for what it was connected to - FString PinKey = FString::Printf(TEXT("%s|%s"), *Node->NodeGuid.ToString(), *Pin->PinName.ToString()); - if (SnapConnByNode.Contains(PinKey)) - { - TArray> WasConnArr; - for (const FPinConnectionRecord& CR : SnapConnByNode[PinKey]) - { - FString OtherGuid = (CR.SourceNodeGuid == Node->NodeGuid.ToString()) ? CR.TargetNodeGuid : CR.SourceNodeGuid; - FString OtherPin = (CR.SourceNodeGuid == Node->NodeGuid.ToString()) ? CR.TargetPinName : CR.SourcePinName; - WasConnArr.Add(MakeShared(FString::Printf(TEXT("%s.%s"), *OtherGuid, *OtherPin))); - } - PinJ->SetArrayField(TEXT("wasConnectedTo"), WasConnArr); - } - PinsArr.Add(MakeShared(PinJ)); - } - Item->SetArrayField(TEXT("pins"), PinsArr); - ResultsArr.Add(MakeShared(Item)); - HighCount++; - } - else - { - // Check for MEDIUM: valid struct but zero data pin connections - bool bHasDataConnection = false; - for (UEdGraphPin* Pin : Node->Pins) - { - if (!Pin || Pin->bHidden) continue; - if (Pin->PinType.PinCategory == UEdGraphSchema_K2::PC_Exec) continue; - if (Pin->LinkedTo.Num() > 0) - { - bHasDataConnection = true; - break; - } - } - - if (!bHasDataConnection) - { - TSharedRef Item = MakeShared(); - Item->SetStringField(TEXT("blueprint"), BP->GetName()); - Item->SetStringField(TEXT("graph"), Graph->GetName()); - Item->SetStringField(TEXT("nodeId"), Node->NodeGuid.ToString()); - Item->SetStringField(TEXT("nodeTitle"), Node->GetNodeTitle(ENodeTitleType::FullTitle).ToString()); - Item->SetStringField(TEXT("structType"), StructTypeName); - Item->SetStringField(TEXT("confidence"), TEXT("MEDIUM")); - Item->SetStringField(TEXT("reason"), TEXT("Break/Make node with valid struct but zero data pin connections")); - - TArray> PinsArr; - for (UEdGraphPin* Pin : Node->Pins) - { - if (!Pin || Pin->bHidden) continue; - if (Pin->PinType.PinCategory == UEdGraphSchema_K2::PC_Exec) continue; - TSharedRef PinJ = MakeShared(); - PinJ->SetStringField(TEXT("name"), Pin->PinName.ToString()); - PinJ->SetStringField(TEXT("type"), Pin->PinType.PinCategory.ToString()); - - FString PinKey = FString::Printf(TEXT("%s|%s"), *Node->NodeGuid.ToString(), *Pin->PinName.ToString()); - if (SnapConnByNode.Contains(PinKey)) - { - TArray> WasConnArr; - for (const FPinConnectionRecord& CR : SnapConnByNode[PinKey]) - { - FString OtherGuid = (CR.SourceNodeGuid == Node->NodeGuid.ToString()) ? CR.TargetNodeGuid : CR.SourceNodeGuid; - FString OtherPin = (CR.SourceNodeGuid == Node->NodeGuid.ToString()) ? CR.TargetPinName : CR.SourcePinName; - WasConnArr.Add(MakeShared(FString::Printf(TEXT("%s.%s"), *OtherGuid, *OtherPin))); - } - PinJ->SetArrayField(TEXT("wasConnectedTo"), WasConnArr); - } - PinsArr.Add(MakeShared(PinJ)); - } - Item->SetArrayField(TEXT("pins"), PinsArr); - ResultsArr.Add(MakeShared(Item)); - MediumCount++; - } - } - } - - // Snapshot-based definite-break detection (applies to ALL node types) - if (SnapshotPtr) - { - for (UEdGraphPin* Pin : Node->Pins) - { - if (!Pin || Pin->bHidden) continue; - FString PinKey = FString::Printf(TEXT("%s|%s"), *Node->NodeGuid.ToString(), *Pin->PinName.ToString()); - if (!SnapConnByNode.Contains(PinKey)) continue; - - // This pin had connections in the snapshot — check if any are now missing - for (const FPinConnectionRecord& CR : SnapConnByNode[PinKey]) - { - // Determine which side is "other" - bool bWeAreSource = (CR.SourceNodeGuid == Node->NodeGuid.ToString() && CR.SourcePinName == Pin->PinName.ToString()); - FString OtherGuid = bWeAreSource ? CR.TargetNodeGuid : CR.SourceNodeGuid; - FString OtherPinName = bWeAreSource ? CR.TargetPinName : CR.SourcePinName; - - // Check if this connection still exists - bool bStillConnected = false; - for (UEdGraphPin* Linked : Pin->LinkedTo) - { - if (Linked && Linked->GetOwningNode() && - Linked->GetOwningNode()->NodeGuid.ToString() == OtherGuid && - Linked->PinName.ToString() == OtherPinName) - { - bStillConnected = true; - break; - } - } - - if (!bStillConnected) - { - // Skip if we already reported this node above via heuristic - // Only add for non-Break/Make nodes, or if the Break/Make node wasn't caught by heuristics - if (!bIsBreakMakeNode) - { - TSharedRef Item = MakeShared(); - Item->SetStringField(TEXT("blueprint"), BP->GetName()); - Item->SetStringField(TEXT("graph"), Graph->GetName()); - Item->SetStringField(TEXT("nodeId"), Node->NodeGuid.ToString()); - Item->SetStringField(TEXT("nodeTitle"), Node->GetNodeTitle(ENodeTitleType::FullTitle).ToString()); - if (!StructTypeName.IsEmpty()) - { - Item->SetStringField(TEXT("structType"), StructTypeName); - } - Item->SetStringField(TEXT("confidence"), TEXT("HIGH")); - Item->SetStringField(TEXT("reason"), TEXT("Connection existed in snapshot but is now missing")); - - TArray> PinsArr; - TSharedRef PinJ = MakeShared(); - PinJ->SetStringField(TEXT("name"), Pin->PinName.ToString()); - PinJ->SetStringField(TEXT("type"), Pin->PinType.PinCategory.ToString()); - TArray> WasConnArr; - WasConnArr.Add(MakeShared(FString::Printf(TEXT("%s.%s"), *OtherGuid, *OtherPinName))); - PinJ->SetArrayField(TEXT("wasConnectedTo"), WasConnArr); - PinsArr.Add(MakeShared(PinJ)); - Item->SetArrayField(TEXT("pins"), PinsArr); - - ResultsArr.Add(MakeShared(Item)); - HighCount++; - } - break; // Only report once per node per snapshot-check pass - } - } - } - } - } - } - } - - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: FindDisconnectedPins complete — %d HIGH, %d MEDIUM, %d total across %d blueprints"), - HighCount, MediumCount, ResultsArr.Num(), BlueprintsScanned); - - Result->SetArrayField(TEXT("results"), ResultsArr); - - TSharedRef Summary = MakeShared(); - Summary->SetNumberField(TEXT("high"), HighCount); - Summary->SetNumberField(TEXT("medium"), MediumCount); - Summary->SetNumberField(TEXT("total"), ResultsArr.Num()); - Summary->SetNumberField(TEXT("blueprintsScanned"), BlueprintsScanned); - Result->SetObjectField(TEXT("summary"), Summary); -} - -// ============================================================ -// HandleAnalyzeRebuildImpact -// ============================================================ - -void FBlueprintMCPServer::HandleAnalyzeRebuildImpact(const FJsonObject* Json, FJsonObject* Result) -{ - FString ModuleName = Json->GetStringField(TEXT("moduleName")); - if (ModuleName.IsEmpty()) - { - return MCPUtils::MakeErrorJson(Result, TEXT("Missing required field: moduleName")); - } - - // Optional struct name filter - TArray StructNameFilter; - const TArray>* StructNamesArr = nullptr; - if (Json->TryGetArrayField(TEXT("structNames"), StructNamesArr)) - { - for (const TSharedPtr& Val : *StructNamesArr) - { - FString Name = Val->AsString(); - if (!Name.IsEmpty()) - { - StructNameFilter.Add(Name); - } - } - } - - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Analyzing rebuild impact for module '%s'"), *ModuleName); - - // Enumerate all UScriptStruct and UEnum objects belonging to this module - TArray FoundStructs; - TArray FoundEnums; - - for (TObjectIterator It; It; ++It) - { - UScriptStruct* Struct = *It; - if (!Struct) continue; - - FString PackageName = Struct->GetOutermost()->GetName(); - if (!PackageName.Contains(ModuleName)) continue; - - // Apply name filter if provided - if (StructNameFilter.Num() > 0) - { - bool bMatch = false; - for (const FString& FilterName : StructNameFilter) - { - // Match with or without F prefix - FString CleanFilter = FilterName; - if (CleanFilter.StartsWith(TEXT("F"))) - { - CleanFilter = CleanFilter.Mid(1); - } - if (Struct->GetName() == FilterName || Struct->GetName() == CleanFilter) - { - bMatch = true; - break; - } - } - if (!bMatch) continue; - } - - FoundStructs.Add(Struct); - } - - for (TObjectIterator It; It; ++It) - { - UEnum* Enum = *It; - if (!Enum) continue; - - FString PackageName = Enum->GetOutermost()->GetName(); - if (!PackageName.Contains(ModuleName)) continue; - - if (StructNameFilter.Num() > 0) - { - bool bMatch = false; - for (const FString& FilterName : StructNameFilter) - { - FString CleanFilter = FilterName; - if (CleanFilter.StartsWith(TEXT("E"))) - { - CleanFilter = CleanFilter.Mid(1); - } - if (Enum->GetName() == FilterName || Enum->GetName() == CleanFilter) - { - bMatch = true; - break; - } - } - if (!bMatch) continue; - } - - FoundEnums.Add(Enum); - } - - // Build list of found types - TArray> TypesFoundArr; - for (UScriptStruct* S : FoundStructs) - { - TSharedRef TJ = MakeShared(); - TJ->SetStringField(TEXT("name"), S->GetName()); - TJ->SetStringField(TEXT("kind"), TEXT("struct")); - TJ->SetStringField(TEXT("package"), S->GetOutermost()->GetName()); - TypesFoundArr.Add(MakeShared(TJ)); - } - for (UEnum* E : FoundEnums) - { - TSharedRef TJ = MakeShared(); - TJ->SetStringField(TEXT("name"), E->GetName()); - TJ->SetStringField(TEXT("kind"), TEXT("enum")); - TJ->SetStringField(TEXT("package"), E->GetOutermost()->GetName()); - TypesFoundArr.Add(MakeShared(TJ)); - } - - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Found %d structs and %d enums in module '%s'"), - FoundStructs.Num(), FoundEnums.Num(), *ModuleName); - - // Build a set of type names for fast lookup - TSet TypeNameSet; - for (UScriptStruct* S : FoundStructs) TypeNameSet.Add(S->GetName()); - for (UEnum* E : FoundEnums) TypeNameSet.Add(E->GetName()); - - // Scan all blueprints for references - struct FBlueprintImpact - { - FString Name; - FString Path; - int32 BreakNodes = 0; - int32 MakeNodes = 0; - int32 Variables = 0; - int32 FunctionParams = 0; - int32 ConnectionsAtRisk = 0; - FString Risk; - }; - - TArray AffectedBlueprints; - int32 TotalBreakMakeNodes = 0; - int32 TotalConnectionsAtRisk = 0; - - for (const FAssetData& Asset : UMCPAssetFinder::GetAssets(UBlueprint::StaticClass())) - { - FString LoadError; - UBlueprint* BP = UMCPAssetFinder::LoadBlueprintOrLevelBlueprint(Asset.AssetName.ToString(), LoadError); - if (!BP) continue; - - FBlueprintImpact Impact; - Impact.Name = BP->GetName(); - Impact.Path = BP->GetPathName(); - - // Scan graphs for Break/Make struct nodes - TArray AllGraphs; - BP->GetAllGraphs(AllGraphs); - - for (UEdGraph* Graph : AllGraphs) - { - if (!Graph) continue; - for (UEdGraphNode* Node : Graph->Nodes) - { - if (!Node) continue; - - if (UK2Node_BreakStruct* BreakNode = Cast(Node)) - { - if (BreakNode->StructType && TypeNameSet.Contains(BreakNode->StructType->GetName())) - { - Impact.BreakNodes++; - // Count data connections at risk - for (UEdGraphPin* Pin : Node->Pins) - { - if (!Pin || Pin->bHidden) continue; - if (Pin->PinType.PinCategory == UEdGraphSchema_K2::PC_Exec) continue; - Impact.ConnectionsAtRisk += Pin->LinkedTo.Num(); - } - } - } - else if (UK2Node_MakeStruct* MakeNode = Cast(Node)) - { - if (MakeNode->StructType && TypeNameSet.Contains(MakeNode->StructType->GetName())) - { - Impact.MakeNodes++; - for (UEdGraphPin* Pin : Node->Pins) - { - if (!Pin || Pin->bHidden) continue; - if (Pin->PinType.PinCategory == UEdGraphSchema_K2::PC_Exec) continue; - Impact.ConnectionsAtRisk += Pin->LinkedTo.Num(); - } - } - } - } - } - - // Scan variables - for (const FBPVariableDescription& Var : BP->NewVariables) - { - if (Var.VarType.PinSubCategoryObject.IsValid()) - { - FString SubObjName = Var.VarType.PinSubCategoryObject->GetName(); - if (TypeNameSet.Contains(SubObjName)) - { - Impact.Variables++; - } - } - } - - // Scan function parameters - for (UEdGraph* Graph : BP->FunctionGraphs) - { - if (!Graph) continue; - for (UEdGraphNode* Node : Graph->Nodes) - { - UK2Node_FunctionEntry* FuncEntry = Cast(Node); - if (!FuncEntry) continue; - - for (const TSharedPtr& PinInfo : FuncEntry->UserDefinedPins) - { - if (!PinInfo.IsValid()) continue; - if (PinInfo->PinType.PinSubCategoryObject.IsValid()) - { - FString SubObjName = PinInfo->PinType.PinSubCategoryObject->GetName(); - if (TypeNameSet.Contains(SubObjName)) - { - Impact.FunctionParams++; - } - } - } - } - } - - // Only include if this BP is affected - if (Impact.BreakNodes > 0 || Impact.MakeNodes > 0 || Impact.Variables > 0 || Impact.FunctionParams > 0) - { - // Classify risk - if (Impact.BreakNodes > 0 || Impact.MakeNodes > 0) - { - Impact.Risk = TEXT("HIGH"); - } - else if (Impact.Variables > 0) - { - Impact.Risk = TEXT("MEDIUM"); - } - else - { - Impact.Risk = TEXT("LOW"); - } - - TotalBreakMakeNodes += Impact.BreakNodes + Impact.MakeNodes; - TotalConnectionsAtRisk += Impact.ConnectionsAtRisk; - AffectedBlueprints.Add(Impact); - } - } - - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Rebuild impact analysis complete — %d affected blueprints, %d Break/Make nodes, %d connections at risk"), - AffectedBlueprints.Num(), TotalBreakMakeNodes, TotalConnectionsAtRisk); - - // Build response - Result->SetStringField(TEXT("moduleName"), ModuleName); - Result->SetArrayField(TEXT("typesFound"), TypesFoundArr); - - TArray> AffectedArr; - for (const FBlueprintImpact& Impact : AffectedBlueprints) - { - TSharedRef BJ = MakeShared(); - BJ->SetStringField(TEXT("name"), Impact.Name); - BJ->SetStringField(TEXT("path"), Impact.Path); - BJ->SetNumberField(TEXT("breakNodes"), Impact.BreakNodes); - BJ->SetNumberField(TEXT("makeNodes"), Impact.MakeNodes); - BJ->SetNumberField(TEXT("variables"), Impact.Variables); - BJ->SetNumberField(TEXT("functionParams"), Impact.FunctionParams); - BJ->SetNumberField(TEXT("connectionsAtRisk"), Impact.ConnectionsAtRisk); - BJ->SetStringField(TEXT("risk"), Impact.Risk); - AffectedArr.Add(MakeShared(BJ)); - } - Result->SetArrayField(TEXT("affectedBlueprints"), AffectedArr); - - TSharedRef Summary = MakeShared(); - Summary->SetNumberField(TEXT("totalBlueprints"), AffectedBlueprints.Num()); - Summary->SetNumberField(TEXT("totalBreakMakeNodes"), TotalBreakMakeNodes); - Summary->SetNumberField(TEXT("totalConnectionsAtRisk"), TotalConnectionsAtRisk); - Result->SetObjectField(TEXT("summary"), Summary); -} diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_StateMachine.cpp b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_StateMachine.cpp deleted file mode 100644 index 396fad91..00000000 --- a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_StateMachine.cpp +++ /dev/null @@ -1,540 +0,0 @@ -#include "MCPAssetFinder.h" -#include "MCPServer.h" -#include "MCPUtils.h" -#include "EdGraph/EdGraph.h" -#include "EdGraph/EdGraphNode.h" -#include "EdGraph/EdGraphPin.h" -#include "Kismet2/KismetEditorUtilities.h" -#include "Animation/AnimBlueprint.h" -#include "Animation/AnimSequence.h" -#include "Animation/BlendSpace.h" -#include "AnimGraphNode_SequencePlayer.h" -#include "AnimGraphNode_BlendSpacePlayer.h" -#include "AnimStateNode.h" -#include "AnimStateTransitionNode.h" -#include "AnimationStateMachineGraph.h" -#include "K2Node_VariableGet.h" - -// ============================================================ -// Tier 2: State Machine Mutation -// ============================================================ - -void FBlueprintMCPServer::HandleAddAnimState(const FJsonObject* Json, FJsonObject* Result) -{ - FString BlueprintName = Json->GetStringField(TEXT("blueprint")); - FString GraphName = Json->GetStringField(TEXT("graph")); - FString StateName = Json->GetStringField(TEXT("stateName")); - - if (BlueprintName.IsEmpty() || GraphName.IsEmpty() || StateName.IsEmpty()) - { - return MCPUtils::MakeErrorJson(Result, TEXT("Missing required fields: blueprint, graph, stateName")); - } - - UAnimationStateMachineGraph* SMGraph = UMCPAssetFinder::LoadAnimStateMachineGraph(BlueprintName, GraphName, Result); - if (!SMGraph) return; - UAnimBlueprint* AnimBP = SMGraph->GetTypedOuter(); - - // Check for duplicate state name - if (MCPUtils::FindStateByName(SMGraph, StateName, nullptr)) - { - return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("State '%s' already exists in graph '%s'"), *StateName, *GraphName)); - } - - // Get position - int32 PosX = Json->HasField(TEXT("posX")) ? (int32)Json->GetNumberField(TEXT("posX")) : 200; - int32 PosY = Json->HasField(TEXT("posY")) ? (int32)Json->GetNumberField(TEXT("posY")) : 0; - - // Create the state node - UAnimStateNode* NewState = NewObject(SMGraph); - NewState->CreateNewGuid(); - NewState->NodePosX = PosX; - NewState->NodePosY = PosY; - - // Set the state name via the bound graph - NewState->PostPlacedNewNode(); - NewState->AllocateDefaultPins(); - - // Rename the bound graph to set the state name - if (NewState->GetBoundGraph()) - { - NewState->GetBoundGraph()->Rename(*StateName, nullptr); - } - - SMGraph->AddNode(NewState, false, false); - NewState->SetFlags(RF_Transactional); - - // Optionally set animation asset - FString AnimAssetName = Json->GetStringField(TEXT("animationAsset")); - if (!AnimAssetName.IsEmpty() && NewState->GetBoundGraph()) - { - // Try to find the animation asset and create a sequence player in the state's inner graph - FAssetData* FoundAnimAsset = UMCPAssetFinder::FindAsset(UAnimSequence::StaticClass(), AnimAssetName); - UAnimSequence* AnimSeq = FoundAnimAsset ? Cast(FoundAnimAsset->GetAsset()) : nullptr; - - if (AnimSeq) - { - UAnimGraphNode_SequencePlayer* SeqNode = NewObject(NewState->GetBoundGraph()); - SeqNode->CreateNewGuid(); - SeqNode->PostPlacedNewNode(); - SeqNode->AllocateDefaultPins(); - SeqNode->SetAnimationAsset(AnimSeq); - SeqNode->NodePosX = 0; - SeqNode->NodePosY = 0; - NewState->GetBoundGraph()->AddNode(SeqNode, false, false); - } - } - - // Compile and save - FKismetEditorUtilities::CompileBlueprint(AnimBP); - bool bSaved = MCPUtils::SaveBlueprintPackage(AnimBP); - - Result->SetBoolField(TEXT("success"), true); - Result->SetStringField(TEXT("stateName"), StateName); - Result->SetStringField(TEXT("graph"), GraphName); - Result->SetStringField(TEXT("nodeId"), NewState->NodeGuid.ToString()); - Result->SetBoolField(TEXT("saved"), bSaved); -} - -void FBlueprintMCPServer::HandleRemoveAnimState(const FJsonObject* Json, FJsonObject* Result) -{ - FString BlueprintName = Json->GetStringField(TEXT("blueprint")); - FString GraphName = Json->GetStringField(TEXT("graph")); - FString StateName = Json->GetStringField(TEXT("stateName")); - - if (BlueprintName.IsEmpty() || GraphName.IsEmpty() || StateName.IsEmpty()) - { - return MCPUtils::MakeErrorJson(Result, TEXT("Missing required fields: blueprint, graph, stateName")); - } - - UAnimationStateMachineGraph* SMGraph = UMCPAssetFinder::LoadAnimStateMachineGraph(BlueprintName, GraphName, Result); - if (!SMGraph) return; - UAnimBlueprint* AnimBP = SMGraph->GetTypedOuter(); - - UAnimStateNode* StateNode = MCPUtils::FindStateByName(SMGraph, StateName, Result); - if (!StateNode) return; - - // Collect and remove transitions connected to this state - TArray TransitionsToRemove; - for (UEdGraphNode* Node : SMGraph->Nodes) - { - if (UAnimStateTransitionNode* TransNode = Cast(Node)) - { - if (TransNode->GetPreviousState() == StateNode || TransNode->GetNextState() == StateNode) - { - TransitionsToRemove.Add(TransNode); - } - } - } - - int32 RemovedTransitions = TransitionsToRemove.Num(); - for (UAnimStateTransitionNode* Trans : TransitionsToRemove) - { - Trans->BreakAllNodeLinks(); - SMGraph->RemoveNode(Trans); - } - - // Remove the state - StateNode->BreakAllNodeLinks(); - SMGraph->RemoveNode(StateNode); - - // Compile and save - FKismetEditorUtilities::CompileBlueprint(AnimBP); - bool bSaved = MCPUtils::SaveBlueprintPackage(AnimBP); - - Result->SetBoolField(TEXT("success"), true); - Result->SetStringField(TEXT("removedState"), StateName); - Result->SetNumberField(TEXT("removedTransitions"), RemovedTransitions); - Result->SetBoolField(TEXT("saved"), bSaved); -} - -void FBlueprintMCPServer::HandleAddAnimTransition(const FJsonObject* Json, FJsonObject* Result) -{ - FString BlueprintName = Json->GetStringField(TEXT("blueprint")); - FString GraphName = Json->GetStringField(TEXT("graph")); - FString FromStateName = Json->GetStringField(TEXT("fromState")); - FString ToStateName = Json->GetStringField(TEXT("toState")); - - if (BlueprintName.IsEmpty() || GraphName.IsEmpty() || FromStateName.IsEmpty() || ToStateName.IsEmpty()) - { - return MCPUtils::MakeErrorJson(Result, TEXT("Missing required fields: blueprint, graph, fromState, toState")); - } - - UAnimationStateMachineGraph* SMGraph = UMCPAssetFinder::LoadAnimStateMachineGraph(BlueprintName, GraphName, Result); - if (!SMGraph) return; - UAnimBlueprint* AnimBP = SMGraph->GetTypedOuter(); - - UAnimStateNode* FromState = MCPUtils::FindStateByName(SMGraph, FromStateName, Result); - if (!FromState) return; - - UAnimStateNode* ToState = MCPUtils::FindStateByName(SMGraph, ToStateName, Result); - if (!ToState) return; - - // Create transition node - UAnimStateTransitionNode* TransNode = NewObject(SMGraph); - TransNode->CreateNewGuid(); - TransNode->PostPlacedNewNode(); - TransNode->AllocateDefaultPins(); - - // Position between the two states - TransNode->NodePosX = (FromState->NodePosX + ToState->NodePosX) / 2; - TransNode->NodePosY = (FromState->NodePosY + ToState->NodePosY) / 2; - - SMGraph->AddNode(TransNode, false, false); - TransNode->SetFlags(RF_Transactional); - - // Connect: FromState output -> Transition input, Transition output -> ToState input - TransNode->CreateConnections(FromState, ToState); - - // Set optional properties - if (Json->HasField(TEXT("crossfadeDuration"))) - { - TransNode->CrossfadeDuration = (float)Json->GetNumberField(TEXT("crossfadeDuration")); - } - if (Json->HasField(TEXT("priority"))) - { - TransNode->PriorityOrder = (int32)Json->GetNumberField(TEXT("priority")); - } - if (Json->HasField(TEXT("bBidirectional"))) - { - TransNode->Bidirectional = Json->GetBoolField(TEXT("bBidirectional")); - } - - // Compile and save - FKismetEditorUtilities::CompileBlueprint(AnimBP); - bool bSaved = MCPUtils::SaveBlueprintPackage(AnimBP); - - Result->SetBoolField(TEXT("success"), true); - Result->SetStringField(TEXT("fromState"), FromStateName); - Result->SetStringField(TEXT("toState"), ToStateName); - Result->SetStringField(TEXT("nodeId"), TransNode->NodeGuid.ToString()); - Result->SetNumberField(TEXT("crossfadeDuration"), TransNode->CrossfadeDuration); - Result->SetNumberField(TEXT("priorityOrder"), TransNode->PriorityOrder); - Result->SetBoolField(TEXT("bBidirectional"), TransNode->Bidirectional); - Result->SetBoolField(TEXT("saved"), bSaved); -} - -void FBlueprintMCPServer::HandleSetTransitionRule(const FJsonObject* Json, FJsonObject* Result) -{ - FString BlueprintName = Json->GetStringField(TEXT("blueprint")); - FString GraphName = Json->GetStringField(TEXT("graph")); - FString FromStateName = Json->GetStringField(TEXT("fromState")); - FString ToStateName = Json->GetStringField(TEXT("toState")); - - if (BlueprintName.IsEmpty() || GraphName.IsEmpty() || FromStateName.IsEmpty() || ToStateName.IsEmpty()) - { - return MCPUtils::MakeErrorJson(Result, TEXT("Missing required fields: blueprint, graph, fromState, toState")); - } - - UAnimationStateMachineGraph* SMGraph = UMCPAssetFinder::LoadAnimStateMachineGraph(BlueprintName, GraphName, Result); - if (!SMGraph) return; - UAnimBlueprint* AnimBP = SMGraph->GetTypedOuter(); - - UAnimStateTransitionNode* TransNode = MCPUtils::FindTransition(SMGraph, FromStateName, ToStateName); - if (!TransNode) - { - return MCPUtils::MakeErrorJson(Result, FString::Printf( - TEXT("Transition from '%s' to '%s' not found in graph '%s'"), - *FromStateName, *ToStateName, *GraphName)); - } - - // Update properties - int32 ChangedCount = 0; - - if (Json->HasField(TEXT("crossfadeDuration"))) - { - TransNode->CrossfadeDuration = (float)Json->GetNumberField(TEXT("crossfadeDuration")); - ChangedCount++; - } - if (Json->HasField(TEXT("blendMode"))) - { - TransNode->BlendMode = (EAlphaBlendOption)(int32)Json->GetNumberField(TEXT("blendMode")); - ChangedCount++; - } - if (Json->HasField(TEXT("priorityOrder"))) - { - TransNode->PriorityOrder = (int32)Json->GetNumberField(TEXT("priorityOrder")); - ChangedCount++; - } - if (Json->HasField(TEXT("logicType"))) - { - TransNode->LogicType = (ETransitionLogicType::Type)(int32)Json->GetNumberField(TEXT("logicType")); - ChangedCount++; - } - if (Json->HasField(TEXT("bBidirectional"))) - { - TransNode->Bidirectional = Json->GetBoolField(TEXT("bBidirectional")); - ChangedCount++; - } - - if (ChangedCount == 0) - { - return MCPUtils::MakeErrorJson(Result, TEXT("No properties to update. Provide at least one of: crossfadeDuration, blendMode, priorityOrder, logicType, bBidirectional")); - } - - // Compile and save - FKismetEditorUtilities::CompileBlueprint(AnimBP); - bool bSaved = MCPUtils::SaveBlueprintPackage(AnimBP); - - Result->SetBoolField(TEXT("success"), true); - Result->SetStringField(TEXT("fromState"), FromStateName); - Result->SetStringField(TEXT("toState"), ToStateName); - Result->SetNumberField(TEXT("propertiesChanged"), ChangedCount); - Result->SetNumberField(TEXT("crossfadeDuration"), TransNode->CrossfadeDuration); - Result->SetNumberField(TEXT("blendMode"), (int32)TransNode->BlendMode); - Result->SetNumberField(TEXT("priorityOrder"), TransNode->PriorityOrder); - Result->SetNumberField(TEXT("logicType"), (int32)TransNode->LogicType.GetValue()); - Result->SetBoolField(TEXT("bBidirectional"), TransNode->Bidirectional); - Result->SetBoolField(TEXT("saved"), bSaved); -} - -void FBlueprintMCPServer::HandleSetStateAnimation(const FJsonObject* Json, FJsonObject* Result) -{ - FString BlueprintName = Json->GetStringField(TEXT("blueprint")); - FString GraphName = Json->GetStringField(TEXT("graph")); - FString StateName = Json->GetStringField(TEXT("stateName")); - FString AnimAssetName = Json->GetStringField(TEXT("animationAsset")); - - if (BlueprintName.IsEmpty() || GraphName.IsEmpty() || StateName.IsEmpty() || AnimAssetName.IsEmpty()) - { - return MCPUtils::MakeErrorJson(Result, TEXT("Missing required fields: blueprint, graph, stateName, animationAsset")); - } - - UAnimationStateMachineGraph* SMGraph = UMCPAssetFinder::LoadAnimStateMachineGraph(BlueprintName, GraphName, Result); - if (!SMGraph) return; - UAnimBlueprint* AnimBP = SMGraph->GetTypedOuter(); - - UAnimStateNode* StateNode = MCPUtils::FindStateByName(SMGraph, StateName, Result); - if (!StateNode) return; - - UEdGraph* InnerGraph = StateNode->GetBoundGraph(); - if (!InnerGraph) - { - return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("State '%s' has no bound graph"), *StateName)); - } - - // Find the animation asset - UAnimSequence* AnimSeq = UMCPAssetFinder::LoadAsset(AnimAssetName, Result); - if (!AnimSeq) return; - - // Find existing SequencePlayer or create one - UAnimGraphNode_SequencePlayer* SeqNode = nullptr; - for (UEdGraphNode* Node : InnerGraph->Nodes) - { - SeqNode = Cast(Node); - if (SeqNode) break; - } - - bool bCreatedNew = false; - if (!SeqNode) - { - SeqNode = NewObject(InnerGraph); - SeqNode->CreateNewGuid(); - SeqNode->PostPlacedNewNode(); - SeqNode->AllocateDefaultPins(); - SeqNode->NodePosX = 0; - SeqNode->NodePosY = 0; - InnerGraph->AddNode(SeqNode, false, false); - bCreatedNew = true; - } - - SeqNode->SetAnimationAsset(AnimSeq); - - // Compile and save - FKismetEditorUtilities::CompileBlueprint(AnimBP); - bool bSaved = MCPUtils::SaveBlueprintPackage(AnimBP); - - Result->SetBoolField(TEXT("success"), true); - Result->SetStringField(TEXT("stateName"), StateName); - Result->SetStringField(TEXT("animationAsset"), AnimSeq->GetName()); - Result->SetBoolField(TEXT("createdNewNode"), bCreatedNew); - Result->SetBoolField(TEXT("saved"), bSaved); -} - -// ============================================================ -// HandleSetStateBlendSpace — place a BlendSpacePlayer in a state -// ============================================================ - -void FBlueprintMCPServer::HandleSetStateBlendSpace(const FJsonObject* Json, FJsonObject* Result) -{ - FString BlueprintName = Json->GetStringField(TEXT("blueprint")); - FString GraphName = Json->GetStringField(TEXT("graph")); - FString StateName = Json->GetStringField(TEXT("stateName")); - FString BlendSpaceName = Json->GetStringField(TEXT("blendSpace")); - FString XVariable = Json->GetStringField(TEXT("xVariable")); - FString YVariable = Json->GetStringField(TEXT("yVariable")); - - if (BlueprintName.IsEmpty() || GraphName.IsEmpty() || StateName.IsEmpty() || BlendSpaceName.IsEmpty()) - { - return MCPUtils::MakeErrorJson(Result, TEXT("Missing required fields: blueprint, graph, stateName, blendSpace")); - } - - UAnimationStateMachineGraph* SMGraph = UMCPAssetFinder::LoadAnimStateMachineGraph(BlueprintName, GraphName, Result); - if (!SMGraph) return; - UAnimBlueprint* AnimBP = SMGraph->GetTypedOuter(); - - UAnimStateNode* StateNode = MCPUtils::FindStateByName(SMGraph, StateName, Result); - if (!StateNode) return; - - UEdGraph* InnerGraph = StateNode->GetBoundGraph(); - if (!InnerGraph) - { - return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("State '%s' has no bound graph"), *StateName)); - } - - // Find the blend space asset - UBlendSpace* BlendSpaceAsset = UMCPAssetFinder::LoadAsset(BlendSpaceName, Result); - if (!BlendSpaceAsset) return; - - // Find existing BlendSpacePlayer or create one - UAnimGraphNode_BlendSpacePlayer* BSNode = nullptr; - for (UEdGraphNode* Node : InnerGraph->Nodes) - { - BSNode = Cast(Node); - if (BSNode) break; - } - - if (!BSNode) - { - BSNode = NewObject(InnerGraph); - BSNode->CreateNewGuid(); - BSNode->PostPlacedNewNode(); - BSNode->AllocateDefaultPins(); - BSNode->NodePosX = 0; - BSNode->NodePosY = 0; - InnerGraph->AddNode(BSNode, false, false); - } - - BSNode->SetAnimationAsset(BlendSpaceAsset); - - // Connect BlendSpacePlayer output to the Output Animation Pose node - { - // Find the AnimGraphNode_Root (Output Pose) in the inner graph - UEdGraphNode* ResultNode = nullptr; - for (UEdGraphNode* Node : InnerGraph->Nodes) - { - if (Node->GetClass()->GetName().Contains(TEXT("AnimGraphNode_Root")) || - Node->GetClass()->GetName().Contains(TEXT("AnimGraphNode_StateResult"))) - { - ResultNode = Node; - break; - } - } - - if (ResultNode) - { - // Find output pose pin on BlendSpacePlayer and input pose pin on result node - UEdGraphPin* BSOutputPin = nullptr; - for (UEdGraphPin* Pin : BSNode->Pins) - { - if (Pin && Pin->Direction == EGPD_Output && Pin->PinType.PinCategory == UEdGraphSchema_K2::PC_Struct) - { - BSOutputPin = Pin; - break; - } - } - - UEdGraphPin* ResultInputPin = nullptr; - for (UEdGraphPin* Pin : ResultNode->Pins) - { - if (Pin && Pin->Direction == EGPD_Input && Pin->PinType.PinCategory == UEdGraphSchema_K2::PC_Struct) - { - ResultInputPin = Pin; - break; - } - } - - if (BSOutputPin && ResultInputPin) - { - // Break existing connections on the result input - ResultInputPin->BreakAllPinLinks(); - const UEdGraphSchema* Schema = InnerGraph->GetSchema(); - if (Schema) - { - Schema->TryCreateConnection(BSOutputPin, ResultInputPin); - } - } - } - } - - // Wire X and Y variables if provided - auto WireVariable = [&](const FString& VarName, const FString& PinName) -> bool - { - if (VarName.IsEmpty()) return false; - - // Verify the variable exists in the blueprint - FName VarFName(*VarName); - bool bVarFound = false; - for (FBPVariableDescription& Var : AnimBP->NewVariables) - { - if (Var.VarName == VarFName) - { - bVarFound = true; - break; - } - } - if (!bVarFound) - { - // Also check parent class properties - if (UClass* GenClass = AnimBP->SkeletonGeneratedClass) - { - if (FProperty* Prop = GenClass->FindPropertyByName(VarFName)) - { - bVarFound = true; - } - } - } - if (!bVarFound) - { - UE_LOG(LogTemp, Warning, TEXT("BlueprintMCP: Variable '%s' not found in '%s', skipping wire"), - *VarName, *BlueprintName); - return false; - } - - // Create a VariableGet node - UK2Node_VariableGet* GetNode = NewObject(InnerGraph); - GetNode->VariableReference.SetSelfMember(VarFName); - GetNode->NodePosX = BSNode->NodePosX - 250; - GetNode->NodePosY = BSNode->NodePosY; - InnerGraph->AddNode(GetNode, false, false); - GetNode->AllocateDefaultPins(); - - // Find the variable output pin - UEdGraphPin* VarOutPin = nullptr; - for (UEdGraphPin* Pin : GetNode->Pins) - { - if (Pin && Pin->Direction == EGPD_Output && Pin->PinName == VarFName) - { - VarOutPin = Pin; - break; - } - } - - // Find the target pin on the BlendSpacePlayer - UEdGraphPin* TargetPin = BSNode->FindPin(FName(*PinName)); - - if (VarOutPin && TargetPin) - { - const UEdGraphSchema* Schema = InnerGraph->GetSchema(); - if (Schema) - { - Schema->TryCreateConnection(VarOutPin, TargetPin); - return true; - } - } - - return false; - }; - - WireVariable(XVariable, TEXT("X")); - WireVariable(YVariable, TEXT("Y")); - - // Compile and save - FKismetEditorUtilities::CompileBlueprint(AnimBP); - bool bSaved = MCPUtils::SaveBlueprintPackage(AnimBP); - - Result->SetBoolField(TEXT("success"), true); - Result->SetStringField(TEXT("stateName"), StateName); - Result->SetStringField(TEXT("blendSpace"), BlendSpaceAsset->GetName()); - Result->SetStringField(TEXT("nodeId"), BSNode->NodeGuid.ToString()); - Result->SetBoolField(TEXT("saved"), bSaved); -} diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_StateMachine.h b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_StateMachine.h new file mode 100644 index 00000000..bd76f7d5 --- /dev/null +++ b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_StateMachine.h @@ -0,0 +1,674 @@ +#pragma once + +#include "CoreMinimal.h" +#include "MCPHandler.h" +#include "MCPAssetFinder.h" +#include "MCPUtils.h" +#include "EdGraph/EdGraph.h" +#include "EdGraph/EdGraphNode.h" +#include "EdGraph/EdGraphPin.h" +#include "Kismet2/KismetEditorUtilities.h" +#include "Animation/AnimBlueprint.h" +#include "Animation/AnimSequence.h" +#include "Animation/BlendSpace.h" +#include "AnimGraphNode_SequencePlayer.h" +#include "AnimGraphNode_BlendSpacePlayer.h" +#include "AnimStateNode.h" +#include "AnimStateTransitionNode.h" +#include "AnimationStateMachineGraph.h" +#include "K2Node_VariableGet.h" +#include "MCPHandlers_StateMachine.generated.h" + +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + +UCLASS(meta=(ToolName="add_anim_state_to_machine")) +class UMCPHandler_AddAnimState : public UObject, public IMCPHandler +{ + GENERATED_BODY() + +public: + UPROPERTY(meta=(Description="Animation Blueprint name or package path")) + FString Blueprint; + + UPROPERTY(meta=(Description="State machine graph name")) + FString Graph; + + UPROPERTY(meta=(Description="Name for the new state")) + FString StateName; + + UPROPERTY(meta=(Optional, Description="X position of the new state node")) + int32 PosX = 200; + + UPROPERTY(meta=(Optional, Description="Y position of the new state node")) + int32 PosY = 0; + + UPROPERTY(meta=(Optional, Description="Animation asset name to assign to the state")) + FString AnimationAsset; + + virtual FString GetDescription() const override + { + return TEXT("Add a new state to an animation state machine graph. " + "Optionally assign an animation asset to the state."); + } + + virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override + { + UAnimationStateMachineGraph* SMGraph = UMCPAssetFinder::LoadAnimStateMachineGraph(Blueprint, Graph, Result); + if (!SMGraph) return; + UAnimBlueprint* AnimBP = SMGraph->GetTypedOuter(); + + // Check for duplicate state name + if (MCPUtils::FindStateByName(SMGraph, StateName, nullptr)) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("State '%s' already exists in graph '%s'"), *StateName, *Graph)); + } + + // Create the state node + UAnimStateNode* NewState = NewObject(SMGraph); + NewState->CreateNewGuid(); + NewState->NodePosX = PosX; + NewState->NodePosY = PosY; + + // Set the state name via the bound graph + NewState->PostPlacedNewNode(); + NewState->AllocateDefaultPins(); + + // Rename the bound graph to set the state name + if (NewState->GetBoundGraph()) + { + NewState->GetBoundGraph()->Rename(*StateName, nullptr); + } + + SMGraph->AddNode(NewState, false, false); + NewState->SetFlags(RF_Transactional); + + // Optionally set animation asset + if (!AnimationAsset.IsEmpty() && NewState->GetBoundGraph()) + { + // Try to find the animation asset and create a sequence player in the state's inner graph + FAssetData* FoundAnimAsset = UMCPAssetFinder::FindAsset(UAnimSequence::StaticClass(), AnimationAsset); + UAnimSequence* AnimSeq = FoundAnimAsset ? Cast(FoundAnimAsset->GetAsset()) : nullptr; + + if (AnimSeq) + { + UAnimGraphNode_SequencePlayer* SeqNode = NewObject(NewState->GetBoundGraph()); + SeqNode->CreateNewGuid(); + SeqNode->PostPlacedNewNode(); + SeqNode->AllocateDefaultPins(); + SeqNode->SetAnimationAsset(AnimSeq); + SeqNode->NodePosX = 0; + SeqNode->NodePosY = 0; + NewState->GetBoundGraph()->AddNode(SeqNode, false, false); + } + } + + // Compile and save + FKismetEditorUtilities::CompileBlueprint(AnimBP); + bool bSaved = MCPUtils::SaveBlueprintPackage(AnimBP); + + Result->SetBoolField(TEXT("success"), true); + Result->SetStringField(TEXT("stateName"), StateName); + Result->SetStringField(TEXT("graph"), Graph); + Result->SetStringField(TEXT("nodeId"), NewState->NodeGuid.ToString()); + Result->SetBoolField(TEXT("saved"), bSaved); + } +}; + +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + +UCLASS(meta=(ToolName="remove_anim_state_from_machine")) +class UMCPHandler_RemoveAnimState : public UObject, public IMCPHandler +{ + GENERATED_BODY() + +public: + UPROPERTY(meta=(Description="Animation Blueprint name or package path")) + FString Blueprint; + + UPROPERTY(meta=(Description="State machine graph name")) + FString Graph; + + UPROPERTY(meta=(Description="Name of the state to remove")) + FString StateName; + + virtual FString GetDescription() const override + { + return TEXT("Remove a state and its connected transitions from an animation state machine graph."); + } + + virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override + { + UAnimationStateMachineGraph* SMGraph = UMCPAssetFinder::LoadAnimStateMachineGraph(Blueprint, Graph, Result); + if (!SMGraph) return; + UAnimBlueprint* AnimBP = SMGraph->GetTypedOuter(); + + UAnimStateNode* StateNode = MCPUtils::FindStateByName(SMGraph, StateName, Result); + if (!StateNode) return; + + // Collect and remove transitions connected to this state + TArray TransitionsToRemove; + for (UEdGraphNode* Node : SMGraph->Nodes) + { + if (UAnimStateTransitionNode* TransNode = Cast(Node)) + { + if ((TransNode->GetPreviousState() == StateNode) || (TransNode->GetNextState() == StateNode)) + { + TransitionsToRemove.Add(TransNode); + } + } + } + + int32 RemovedTransitions = TransitionsToRemove.Num(); + for (UAnimStateTransitionNode* Trans : TransitionsToRemove) + { + Trans->BreakAllNodeLinks(); + SMGraph->RemoveNode(Trans); + } + + // Remove the state + StateNode->BreakAllNodeLinks(); + SMGraph->RemoveNode(StateNode); + + // Compile and save + FKismetEditorUtilities::CompileBlueprint(AnimBP); + bool bSaved = MCPUtils::SaveBlueprintPackage(AnimBP); + + Result->SetBoolField(TEXT("success"), true); + Result->SetStringField(TEXT("removedState"), StateName); + Result->SetNumberField(TEXT("removedTransitions"), RemovedTransitions); + Result->SetBoolField(TEXT("saved"), bSaved); + } +}; + +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + +UCLASS(meta=(ToolName="add_anim_state_transition")) +class UMCPHandler_AddAnimTransition : public UObject, public IMCPHandler +{ + GENERATED_BODY() + +public: + UPROPERTY(meta=(Description="Animation Blueprint name or package path")) + FString Blueprint; + + UPROPERTY(meta=(Description="State machine graph name")) + FString Graph; + + UPROPERTY(meta=(Description="Name of the source state")) + FString FromState; + + UPROPERTY(meta=(Description="Name of the target state")) + FString ToState; + + UPROPERTY(meta=(Optional, Description="Crossfade duration in seconds")) + float CrossfadeDuration = 0.0f; + + UPROPERTY(meta=(Optional, Description="Transition priority order")) + int32 Priority = 0; + + UPROPERTY(meta=(Optional, Description="Whether the transition is bidirectional")) + bool BBidirectional = false; + + virtual FString GetDescription() const override + { + return TEXT("Add a transition between two states in an animation state machine graph."); + } + + virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override + { + UAnimationStateMachineGraph* SMGraph = UMCPAssetFinder::LoadAnimStateMachineGraph(Blueprint, Graph, Result); + if (!SMGraph) return; + UAnimBlueprint* AnimBP = SMGraph->GetTypedOuter(); + + UAnimStateNode* FromStateNode = MCPUtils::FindStateByName(SMGraph, FromState, Result); + if (!FromStateNode) return; + + UAnimStateNode* ToStateNode = MCPUtils::FindStateByName(SMGraph, ToState, Result); + if (!ToStateNode) return; + + // Create transition node + UAnimStateTransitionNode* TransNode = NewObject(SMGraph); + TransNode->CreateNewGuid(); + TransNode->PostPlacedNewNode(); + TransNode->AllocateDefaultPins(); + + // Position between the two states + TransNode->NodePosX = (FromStateNode->NodePosX + ToStateNode->NodePosX) / 2; + TransNode->NodePosY = (FromStateNode->NodePosY + ToStateNode->NodePosY) / 2; + + SMGraph->AddNode(TransNode, false, false); + TransNode->SetFlags(RF_Transactional); + + // Connect: FromState output -> Transition input, Transition output -> ToState input + TransNode->CreateConnections(FromStateNode, ToStateNode); + + // Set optional properties + if (Json->HasField(TEXT("crossfadeDuration"))) + { + TransNode->CrossfadeDuration = CrossfadeDuration; + } + if (Json->HasField(TEXT("priority"))) + { + TransNode->PriorityOrder = Priority; + } + if (Json->HasField(TEXT("bBidirectional"))) + { + TransNode->Bidirectional = BBidirectional; + } + + // Compile and save + FKismetEditorUtilities::CompileBlueprint(AnimBP); + bool bSaved = MCPUtils::SaveBlueprintPackage(AnimBP); + + Result->SetBoolField(TEXT("success"), true); + Result->SetStringField(TEXT("fromState"), FromState); + Result->SetStringField(TEXT("toState"), ToState); + Result->SetStringField(TEXT("nodeId"), TransNode->NodeGuid.ToString()); + Result->SetNumberField(TEXT("crossfadeDuration"), TransNode->CrossfadeDuration); + Result->SetNumberField(TEXT("priorityOrder"), TransNode->PriorityOrder); + Result->SetBoolField(TEXT("bBidirectional"), TransNode->Bidirectional); + Result->SetBoolField(TEXT("saved"), bSaved); + } +}; + +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + +UCLASS(meta=(ToolName="set_anim_transition_rule")) +class UMCPHandler_SetTransitionRule : public UObject, public IMCPHandler +{ + GENERATED_BODY() + +public: + UPROPERTY(meta=(Description="Animation Blueprint name or package path")) + FString Blueprint; + + UPROPERTY(meta=(Description="State machine graph name")) + FString Graph; + + UPROPERTY(meta=(Description="Name of the source state")) + FString FromState; + + UPROPERTY(meta=(Description="Name of the target state")) + FString ToState; + + UPROPERTY(meta=(Optional, Description="Crossfade duration in seconds")) + float CrossfadeDuration = 0.0f; + + UPROPERTY(meta=(Optional, Description="Blend mode (as integer enum value)")) + int32 BlendMode = 0; + + UPROPERTY(meta=(Optional, Description="Transition priority order")) + int32 PriorityOrder = 0; + + UPROPERTY(meta=(Optional, Description="Logic type (as integer enum value)")) + int32 LogicType = 0; + + UPROPERTY(meta=(Optional, Description="Whether the transition is bidirectional")) + bool BBidirectional = false; + + virtual FString GetDescription() const override + { + return TEXT("Update properties on an existing transition between two states in an animation state machine."); + } + + virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override + { + UAnimationStateMachineGraph* SMGraph = UMCPAssetFinder::LoadAnimStateMachineGraph(Blueprint, Graph, Result); + if (!SMGraph) return; + UAnimBlueprint* AnimBP = SMGraph->GetTypedOuter(); + + UAnimStateTransitionNode* TransNode = MCPUtils::FindTransition(SMGraph, FromState, ToState); + if (!TransNode) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf( + TEXT("Transition from '%s' to '%s' not found in graph '%s'"), + *FromState, *ToState, *Graph)); + } + + // Update properties + int32 ChangedCount = 0; + + if (Json->HasField(TEXT("crossfadeDuration"))) + { + TransNode->CrossfadeDuration = CrossfadeDuration; + ChangedCount++; + } + if (Json->HasField(TEXT("blendMode"))) + { + TransNode->BlendMode = (EAlphaBlendOption)BlendMode; + ChangedCount++; + } + if (Json->HasField(TEXT("priorityOrder"))) + { + TransNode->PriorityOrder = PriorityOrder; + ChangedCount++; + } + if (Json->HasField(TEXT("logicType"))) + { + TransNode->LogicType = (ETransitionLogicType::Type)LogicType; + ChangedCount++; + } + if (Json->HasField(TEXT("bBidirectional"))) + { + TransNode->Bidirectional = BBidirectional; + ChangedCount++; + } + + if (ChangedCount == 0) + { + return MCPUtils::MakeErrorJson(Result, TEXT("No properties to update. Provide at least one of: crossfadeDuration, blendMode, priorityOrder, logicType, bBidirectional")); + } + + // Compile and save + FKismetEditorUtilities::CompileBlueprint(AnimBP); + bool bSaved = MCPUtils::SaveBlueprintPackage(AnimBP); + + Result->SetBoolField(TEXT("success"), true); + Result->SetStringField(TEXT("fromState"), FromState); + Result->SetStringField(TEXT("toState"), ToState); + Result->SetNumberField(TEXT("propertiesChanged"), ChangedCount); + Result->SetNumberField(TEXT("crossfadeDuration"), TransNode->CrossfadeDuration); + Result->SetNumberField(TEXT("blendMode"), (int32)TransNode->BlendMode); + Result->SetNumberField(TEXT("priorityOrder"), TransNode->PriorityOrder); + Result->SetNumberField(TEXT("logicType"), (int32)TransNode->LogicType.GetValue()); + Result->SetBoolField(TEXT("bBidirectional"), TransNode->Bidirectional); + Result->SetBoolField(TEXT("saved"), bSaved); + } +}; + +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + +UCLASS(meta=(ToolName="set_anim_state_animation")) +class UMCPHandler_SetStateAnimation : public UObject, public IMCPHandler +{ + GENERATED_BODY() + +public: + UPROPERTY(meta=(Description="Animation Blueprint name or package path")) + FString Blueprint; + + UPROPERTY(meta=(Description="State machine graph name")) + FString Graph; + + UPROPERTY(meta=(Description="Name of the state to modify")) + FString StateName; + + UPROPERTY(meta=(Description="Animation asset name to assign")) + FString AnimationAsset; + + virtual FString GetDescription() const override + { + return TEXT("Set or replace the animation sequence played by a state in an animation state machine."); + } + + virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override + { + UAnimationStateMachineGraph* SMGraph = UMCPAssetFinder::LoadAnimStateMachineGraph(Blueprint, Graph, Result); + if (!SMGraph) return; + UAnimBlueprint* AnimBP = SMGraph->GetTypedOuter(); + + UAnimStateNode* StateNode = MCPUtils::FindStateByName(SMGraph, StateName, Result); + if (!StateNode) return; + + UEdGraph* InnerGraph = StateNode->GetBoundGraph(); + if (!InnerGraph) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("State '%s' has no bound graph"), *StateName)); + } + + // Find the animation asset + UAnimSequence* AnimSeq = UMCPAssetFinder::LoadAsset(AnimationAsset, Result); + if (!AnimSeq) return; + + // Find existing SequencePlayer or create one + UAnimGraphNode_SequencePlayer* SeqNode = nullptr; + for (UEdGraphNode* Node : InnerGraph->Nodes) + { + SeqNode = Cast(Node); + if (SeqNode) break; + } + + bool bCreatedNew = false; + if (!SeqNode) + { + SeqNode = NewObject(InnerGraph); + SeqNode->CreateNewGuid(); + SeqNode->PostPlacedNewNode(); + SeqNode->AllocateDefaultPins(); + SeqNode->NodePosX = 0; + SeqNode->NodePosY = 0; + InnerGraph->AddNode(SeqNode, false, false); + bCreatedNew = true; + } + + SeqNode->SetAnimationAsset(AnimSeq); + + // Compile and save + FKismetEditorUtilities::CompileBlueprint(AnimBP); + bool bSaved = MCPUtils::SaveBlueprintPackage(AnimBP); + + Result->SetBoolField(TEXT("success"), true); + Result->SetStringField(TEXT("stateName"), StateName); + Result->SetStringField(TEXT("animationAsset"), AnimSeq->GetName()); + Result->SetBoolField(TEXT("createdNewNode"), bCreatedNew); + Result->SetBoolField(TEXT("saved"), bSaved); + } +}; + +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + +UCLASS(meta=(ToolName="set_anim_state_blend_space")) +class UMCPHandler_SetStateBlendSpace : public UObject, public IMCPHandler +{ + GENERATED_BODY() + +public: + UPROPERTY(meta=(Description="Animation Blueprint name or package path")) + FString Blueprint; + + UPROPERTY(meta=(Description="State machine graph name")) + FString Graph; + + UPROPERTY(meta=(Description="Name of the state to modify")) + FString StateName; + + UPROPERTY(meta=(Description="Blend Space asset name or path")) + FString BlendSpace; + + UPROPERTY(meta=(Optional, Description="Blueprint variable name to wire to the X axis input")) + FString XVariable; + + UPROPERTY(meta=(Optional, Description="Blueprint variable name to wire to the Y axis input")) + FString YVariable; + + virtual FString GetDescription() const override + { + return TEXT("Place a BlendSpacePlayer in a state's inner graph, connect it to the output pose, " + "and optionally wire blueprint variables to the X and Y axis inputs."); + } + + virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override + { + UAnimationStateMachineGraph* SMGraph = UMCPAssetFinder::LoadAnimStateMachineGraph(Blueprint, Graph, Result); + if (!SMGraph) return; + UAnimBlueprint* AnimBP = SMGraph->GetTypedOuter(); + + UAnimStateNode* StateNode = MCPUtils::FindStateByName(SMGraph, StateName, Result); + if (!StateNode) return; + + UEdGraph* InnerGraph = StateNode->GetBoundGraph(); + if (!InnerGraph) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("State '%s' has no bound graph"), *StateName)); + } + + // Find the blend space asset + UBlendSpace* BlendSpaceAsset = UMCPAssetFinder::LoadAsset(BlendSpace, Result); + if (!BlendSpaceAsset) return; + + // Find existing BlendSpacePlayer or create one + UAnimGraphNode_BlendSpacePlayer* BSNode = nullptr; + for (UEdGraphNode* Node : InnerGraph->Nodes) + { + BSNode = Cast(Node); + if (BSNode) break; + } + + if (!BSNode) + { + BSNode = NewObject(InnerGraph); + BSNode->CreateNewGuid(); + BSNode->PostPlacedNewNode(); + BSNode->AllocateDefaultPins(); + BSNode->NodePosX = 0; + BSNode->NodePosY = 0; + InnerGraph->AddNode(BSNode, false, false); + } + + BSNode->SetAnimationAsset(BlendSpaceAsset); + + // Connect BlendSpacePlayer output to the Output Animation Pose node + { + // Find the AnimGraphNode_Root (Output Pose) in the inner graph + UEdGraphNode* ResultNode = nullptr; + for (UEdGraphNode* Node : InnerGraph->Nodes) + { + if (Node->GetClass()->GetName().Contains(TEXT("AnimGraphNode_Root")) || + Node->GetClass()->GetName().Contains(TEXT("AnimGraphNode_StateResult"))) + { + ResultNode = Node; + break; + } + } + + if (ResultNode) + { + // Find output pose pin on BlendSpacePlayer and input pose pin on result node + UEdGraphPin* BSOutputPin = nullptr; + for (UEdGraphPin* Pin : BSNode->Pins) + { + if (Pin && (Pin->Direction == EGPD_Output) && (Pin->PinType.PinCategory == UEdGraphSchema_K2::PC_Struct)) + { + BSOutputPin = Pin; + break; + } + } + + UEdGraphPin* ResultInputPin = nullptr; + for (UEdGraphPin* Pin : ResultNode->Pins) + { + if (Pin && (Pin->Direction == EGPD_Input) && (Pin->PinType.PinCategory == UEdGraphSchema_K2::PC_Struct)) + { + ResultInputPin = Pin; + break; + } + } + + if (BSOutputPin && ResultInputPin) + { + // Break existing connections on the result input + ResultInputPin->BreakAllPinLinks(); + const UEdGraphSchema* Schema = InnerGraph->GetSchema(); + if (Schema) + { + Schema->TryCreateConnection(BSOutputPin, ResultInputPin); + } + } + } + } + + // Wire X and Y variables if provided + auto WireVariable = [&](const FString& VarName, const FString& PinName) -> bool + { + if (VarName.IsEmpty()) return false; + + // Verify the variable exists in the blueprint + FName VarFName(*VarName); + bool bVarFound = false; + for (FBPVariableDescription& Var : AnimBP->NewVariables) + { + if (Var.VarName == VarFName) + { + bVarFound = true; + break; + } + } + if (!bVarFound) + { + // Also check parent class properties + if (UClass* GenClass = AnimBP->SkeletonGeneratedClass) + { + if (FProperty* Prop = GenClass->FindPropertyByName(VarFName)) + { + bVarFound = true; + } + } + } + if (!bVarFound) + { + UE_LOG(LogTemp, Warning, TEXT("BlueprintMCP: Variable '%s' not found in '%s', skipping wire"), + *VarName, *Blueprint); + return false; + } + + // Create a VariableGet node + UK2Node_VariableGet* GetNode = NewObject(InnerGraph); + GetNode->VariableReference.SetSelfMember(VarFName); + GetNode->NodePosX = BSNode->NodePosX - 250; + GetNode->NodePosY = BSNode->NodePosY; + InnerGraph->AddNode(GetNode, false, false); + GetNode->AllocateDefaultPins(); + + // Find the variable output pin + UEdGraphPin* VarOutPin = nullptr; + for (UEdGraphPin* Pin : GetNode->Pins) + { + if (Pin && (Pin->Direction == EGPD_Output) && (Pin->PinName == VarFName)) + { + VarOutPin = Pin; + break; + } + } + + // Find the target pin on the BlendSpacePlayer + UEdGraphPin* TargetPin = BSNode->FindPin(FName(*PinName)); + + if (VarOutPin && TargetPin) + { + const UEdGraphSchema* Schema = InnerGraph->GetSchema(); + if (Schema) + { + Schema->TryCreateConnection(VarOutPin, TargetPin); + return true; + } + } + + return false; + }; + + WireVariable(XVariable, TEXT("X")); + WireVariable(YVariable, TEXT("Y")); + + // Compile and save + FKismetEditorUtilities::CompileBlueprint(AnimBP); + bool bSaved = MCPUtils::SaveBlueprintPackage(AnimBP); + + Result->SetBoolField(TEXT("success"), true); + Result->SetStringField(TEXT("stateName"), StateName); + Result->SetStringField(TEXT("blendSpace"), BlendSpaceAsset->GetName()); + Result->SetStringField(TEXT("nodeId"), BSNode->NodeGuid.ToString()); + Result->SetBoolField(TEXT("saved"), bSaved); + } +}; diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Variables.cpp b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Variables.cpp deleted file mode 100644 index b3aa6b4e..00000000 --- a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Variables.cpp +++ /dev/null @@ -1,583 +0,0 @@ -#include "MCPAssetFinder.h" -#include "MCPServer.h" -#include "MCPUtils.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 -// ============================================================ - -void FBlueprintMCPServer::HandleChangeVariableType(const FJsonObject* Json, FJsonObject* Result) -{ - 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 MCPUtils::MakeErrorJson(Result, TEXT("Missing required fields: blueprint, variable, newType")); - } - - // Load Blueprint - FString LoadError; - UBlueprint* BP = UMCPAssetFinder::LoadBlueprintOrLevelBlueprint(BlueprintName, LoadError); - if (!BP) - { - return MCPUtils::MakeErrorJson(Result, LoadError); - } - - // Verify variable exists - bool bVarFound = false; - for (const FBPVariableDescription& Var : BP->NewVariables) - { - if (Var.VarName.ToString() == VariableName) - { - bVarFound = true; - break; - } - } - if (!bVarFound) - { - return MCPUtils::MakeErrorJson(Result, 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; - } - - if (!MCPUtils::ResolveTypeFromString(ResolveInput, NewPinType, Result)) - return; - - // 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> AffectedNodes; - TArray AllGraphs; - BP->GetAllGraphs(AllGraphs); - for (UEdGraph* Graph : AllGraphs) - { - if (!Graph) continue; - for (UEdGraphNode* Node : Graph->Nodes) - { - if (!Node) continue; - if (auto* VG = Cast(Node)) - { - if (VG->GetVarName().ToString() == VariableName) - { - TSharedRef AffNode = MakeShared(); - 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> AffPins; - for (UEdGraphPin* Pin : VG->Pins) - { - if (Pin && Pin->LinkedTo.Num() > 0 && Pin->Direction == EGPD_Output) - { - AffPins.Add(MakeShared( - FString::Printf(TEXT("%s (connected to %d pin(s))"), - *Pin->PinName.ToString(), Pin->LinkedTo.Num()))); - } - } - AffNode->SetArrayField(TEXT("affectedPins"), AffPins); - AffectedNodes.Add(MakeShared(AffNode)); - } - } - else if (auto* VS = Cast(Node)) - { - if (VS->GetVarName().ToString() == VariableName) - { - TSharedRef AffNode = MakeShared(); - AffNode->SetStringField(TEXT("nodeId"), VS->NodeGuid.ToString()); - AffNode->SetStringField(TEXT("nodeType"), TEXT("VariableSet")); - AffNode->SetStringField(TEXT("graph"), Graph->GetName()); - TArray> AffPins; - for (UEdGraphPin* Pin : VS->Pins) - { - if (Pin && Pin->LinkedTo.Num() > 0) - { - AffPins.Add(MakeShared( - FString::Printf(TEXT("%s (connected to %d pin(s))"), - *Pin->PinName.ToString(), Pin->LinkedTo.Num()))); - } - } - AffNode->SetArrayField(TEXT("affectedPins"), AffPins); - AffectedNodes.Add(MakeShared(AffNode)); - } - } - } - } - - if (bDryRun) - { - 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; - } - - // 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 = MCPUtils::SaveBlueprintPackage(BP); - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Variable type changed, save %s"), - bSaved ? TEXT("succeeded") : TEXT("failed")); - - // Return updated variable state - TSharedRef UpdatedVar = MakeShared(); - 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; - } - } - - 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); -} - -// ============================================================ -// HandleAddVariable — add a new member variable to a Blueprint -// ============================================================ - -void FBlueprintMCPServer::HandleAddVariable(const FJsonObject* Json, FJsonObject* Result) -{ - 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 MCPUtils::MakeErrorJson(Result, 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 = UMCPAssetFinder::LoadBlueprintOrLevelBlueprint(BlueprintName, LoadError); - if (!BP) - { - return MCPUtils::MakeErrorJson(Result, LoadError); - } - - // Check for duplicate variable name - FName VarFName(*VariableName); - for (const FBPVariableDescription& Var : BP->NewVariables) - { - if (Var.VarName == VarFName) - { - return MCPUtils::MakeErrorJson(Result, FString::Printf( - TEXT("Variable '%s' already exists in Blueprint '%s'"), *VariableName, *BlueprintName)); - } - } - - // Resolve the type using the shared helper - FEdGraphPinType PinType; - if (!MCPUtils::ResolveTypeFromString(VariableType, PinType, Result)) - return; - - // 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 MCPUtils::MakeErrorJson(Result, 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 = MCPUtils::SaveBlueprintPackage(BP); - - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Added variable '%s' to '%s' (saved: %s)"), - *VariableName, *BlueprintName, bSaved ? TEXT("true") : TEXT("false")); - - 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); -} - -// ============================================================ -// HandleRemoveVariable — remove a member variable from a Blueprint -// ============================================================ - -void FBlueprintMCPServer::HandleRemoveVariable(const FJsonObject* Json, FJsonObject* Result) -{ - FString BlueprintName = Json->GetStringField(TEXT("blueprint")); - FString VariableName = Json->GetStringField(TEXT("variableName")); - - if (BlueprintName.IsEmpty() || VariableName.IsEmpty()) - { - return MCPUtils::MakeErrorJson(Result, TEXT("Missing required fields: blueprint, variableName")); - } - - // Load Blueprint - FString LoadError; - UBlueprint* BP = UMCPAssetFinder::LoadBlueprintOrLevelBlueprint(BlueprintName, LoadError); - if (!BP) - { - return MCPUtils::MakeErrorJson(Result, 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> AvailVars; - for (const FBPVariableDescription& Var : BP->NewVariables) - { - AvailVars.Add(MakeShared(Var.VarName.ToString())); - } - - MCPUtils::MakeErrorJson(Result, FString::Printf( - TEXT("Variable '%s' not found in Blueprint '%s'"), *VariableName, *BlueprintName)); - Result->SetArrayField(TEXT("availableVariables"), AvailVars); - return; - } - - 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 = MCPUtils::SaveBlueprintPackage(BP); - - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Removed variable '%s' from '%s' (saved: %s)"), - *VariableName, *BlueprintName, bSaved ? TEXT("true") : TEXT("false")); - - Result->SetBoolField(TEXT("success"), true); - Result->SetStringField(TEXT("blueprint"), BlueprintName); - Result->SetStringField(TEXT("variableName"), VariableName); - Result->SetBoolField(TEXT("saved"), bSaved); -} - -// ============================================================ -// HandleSetVariableMetadata — set variable properties (category, tooltip, replication, etc.) -// ============================================================ - -void FBlueprintMCPServer::HandleSetVariableMetadata(const FJsonObject* Json, FJsonObject* Result) -{ - FString BlueprintName = Json->GetStringField(TEXT("blueprint")); - FString VariableName = Json->GetStringField(TEXT("variable")); - - if (BlueprintName.IsEmpty() || VariableName.IsEmpty()) - { - return MCPUtils::MakeErrorJson(Result, TEXT("Missing required fields: blueprint, variable")); - } - - // Load Blueprint - FString LoadError; - UBlueprint* BP = UMCPAssetFinder::LoadBlueprintOrLevelBlueprint(BlueprintName, LoadError); - if (!BP) - { - return MCPUtils::MakeErrorJson(Result, 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> AvailableVars; - for (const FBPVariableDescription& Var : BP->NewVariables) - { - AvailableVars.Add(MakeShared(Var.VarName.ToString())); - } - MCPUtils::MakeErrorJson(Result, FString::Printf( - TEXT("Variable '%s' not found in Blueprint '%s'"), *VariableName, *BlueprintName)); - Result->SetArrayField(TEXT("availableVariables"), AvailableVars); - return; - } - - TArray> 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 Change = MakeShared(); - Change->SetStringField(TEXT("field"), TEXT("category")); - Change->SetStringField(TEXT("oldValue"), OldCategory); - Change->SetStringField(TEXT("newValue"), NewCategory); - Changes.Add(MakeShared(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 Change = MakeShared(); - Change->SetStringField(TEXT("field"), TEXT("tooltip")); - Change->SetStringField(TEXT("oldValue"), OldTooltip); - Change->SetStringField(TEXT("newValue"), NewTooltip); - Changes.Add(MakeShared(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 MCPUtils::MakeErrorJson(Result, FString::Printf( - TEXT("Invalid replication value '%s'. Valid: none, replicated, repNotify"), *ReplicationStr)); - } - - TSharedRef Change = MakeShared(); - Change->SetStringField(TEXT("field"), TEXT("replication")); - Change->SetStringField(TEXT("newValue"), ReplicationStr); - Changes.Add(MakeShared(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 Change = MakeShared(); - 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(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 Change = MakeShared(); - 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(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 MCPUtils::MakeErrorJson(Result, FString::Printf( - TEXT("Invalid editability value '%s'. Valid: editAnywhere, editDefaultsOnly, editInstanceOnly, none"), - *Editability)); - } - - TSharedRef Change = MakeShared(); - Change->SetStringField(TEXT("field"), TEXT("editability")); - Change->SetStringField(TEXT("newValue"), Editability); - Changes.Add(MakeShared(Change)); - } - - if (Changes.Num() == 0) - { - return MCPUtils::MakeErrorJson(Result, 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 = MCPUtils::SaveBlueprintPackage(BP); - - 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); -} diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Variables.h b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Variables.h new file mode 100644 index 00000000..2b3f1883 --- /dev/null +++ b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Variables.h @@ -0,0 +1,625 @@ +#pragma once + +#include "CoreMinimal.h" +#include "MCPHandler.h" +#include "MCPAssetFinder.h" +#include "MCPUtils.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 "MCPHandlers_Variables.generated.h" + +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + +UCLASS(meta=(ToolName="change_blueprint_variable_type")) +class UMCPHandler_ChangeVariableType : public UObject, public IMCPHandler +{ + GENERATED_BODY() + +public: + UPROPERTY(meta=(Description="Blueprint name or package path")) + FString Blueprint; + + UPROPERTY(meta=(Description="Name of the variable to change")) + FString Variable; + + UPROPERTY(meta=(Description="New type name for the variable")) + FString NewType; + + UPROPERTY(meta=(Optional, Description="Type category: object, softobject, class, softclass, interface, struct, enum")) + FString TypeCategory; + + UPROPERTY(meta=(Optional, Description="If true, analyze the change without applying it")) + bool DryRun = false; + + virtual FString GetDescription() const override + { + return TEXT("Change the type of a Blueprint member variable. " + "Supports dry-run mode to preview affected nodes before committing."); + } + + virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override + { + // Load Blueprint + FString LoadError; + UBlueprint* BP = UMCPAssetFinder::LoadBlueprintOrLevelBlueprint(Blueprint, LoadError); + if (!BP) + { + return MCPUtils::MakeErrorJson(Result, LoadError); + } + + // Verify variable exists + bool bVarFound = false; + for (const FBPVariableDescription& Var : BP->NewVariables) + { + if (Var.VarName.ToString() == Variable) + { + bVarFound = true; + break; + } + } + if (!bVarFound) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Variable '%s' not found in Blueprint '%s'"), *Variable, *Blueprint)); + } + + // Build the new pin type using shared resolver + FEdGraphPinType NewPinType; + FString ResolveInput = NewType; + + // 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(":") + NewType; + } + + if (!MCPUtils::ResolveTypeFromString(ResolveInput, NewPinType, Result)) + return; + + // Derive typeCategory from the resolved pin type for the response + FString ResolvedTypeCategory = TypeCategory; + if (ResolvedTypeCategory.IsEmpty()) + { + if (NewPinType.PinCategory == UEdGraphSchema_K2::PC_Struct) + ResolvedTypeCategory = TEXT("struct"); + else if (NewPinType.PinCategory == UEdGraphSchema_K2::PC_Enum || NewPinType.PinCategory == UEdGraphSchema_K2::PC_Byte) + ResolvedTypeCategory = TEXT("enum"); + else if (NewPinType.PinCategory == UEdGraphSchema_K2::PC_Object) + ResolvedTypeCategory = TEXT("object"); + else if (NewPinType.PinCategory == UEdGraphSchema_K2::PC_SoftObject) + ResolvedTypeCategory = TEXT("softobject"); + else if (NewPinType.PinCategory == UEdGraphSchema_K2::PC_Class) + ResolvedTypeCategory = TEXT("class"); + else if (NewPinType.PinCategory == UEdGraphSchema_K2::PC_SoftClass) + ResolvedTypeCategory = TEXT("softclass"); + else if (NewPinType.PinCategory == UEdGraphSchema_K2::PC_Interface) + ResolvedTypeCategory = TEXT("interface"); + else + ResolvedTypeCategory = NewPinType.PinCategory.ToString(); + } + + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: %s variable '%s' in '%s' to %s (%s)"), + DryRun ? TEXT("[DRY RUN] Analyzing change of") : TEXT("Changing"), + *Variable, *Blueprint, *NewType, *ResolvedTypeCategory); + + // Analyze affected nodes (get/set nodes for this variable) + TArray> AffectedNodes; + TArray AllGraphs; + BP->GetAllGraphs(AllGraphs); + for (UEdGraph* Graph : AllGraphs) + { + if (!Graph) continue; + for (UEdGraphNode* Node : Graph->Nodes) + { + if (!Node) continue; + if (auto* VG = Cast(Node)) + { + if (VG->GetVarName().ToString() == Variable) + { + TSharedRef AffNode = MakeShared(); + 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> AffPins; + for (UEdGraphPin* Pin : VG->Pins) + { + if (Pin && Pin->LinkedTo.Num() > 0 && Pin->Direction == EGPD_Output) + { + AffPins.Add(MakeShared( + FString::Printf(TEXT("%s (connected to %d pin(s))"), + *Pin->PinName.ToString(), Pin->LinkedTo.Num()))); + } + } + AffNode->SetArrayField(TEXT("affectedPins"), AffPins); + AffectedNodes.Add(MakeShared(AffNode)); + } + } + else if (auto* VS = Cast(Node)) + { + if (VS->GetVarName().ToString() == Variable) + { + TSharedRef AffNode = MakeShared(); + AffNode->SetStringField(TEXT("nodeId"), VS->NodeGuid.ToString()); + AffNode->SetStringField(TEXT("nodeType"), TEXT("VariableSet")); + AffNode->SetStringField(TEXT("graph"), Graph->GetName()); + TArray> AffPins; + for (UEdGraphPin* Pin : VS->Pins) + { + if (Pin && Pin->LinkedTo.Num() > 0) + { + AffPins.Add(MakeShared( + FString::Printf(TEXT("%s (connected to %d pin(s))"), + *Pin->PinName.ToString(), Pin->LinkedTo.Num()))); + } + } + AffNode->SetArrayField(TEXT("affectedPins"), AffPins); + AffectedNodes.Add(MakeShared(AffNode)); + } + } + } + } + + if (DryRun) + { + Result->SetBoolField(TEXT("dryRun"), true); + Result->SetStringField(TEXT("blueprint"), Blueprint); + Result->SetStringField(TEXT("variable"), Variable); + Result->SetStringField(TEXT("newType"), NewType); + Result->SetStringField(TEXT("typeCategory"), ResolvedTypeCategory); + Result->SetNumberField(TEXT("affectedNodeCount"), AffectedNodes.Num()); + Result->SetArrayField(TEXT("affectedNodes"), AffectedNodes); + return; + } + + // Directly modify the variable type in the description array. + for (FBPVariableDescription& Var : BP->NewVariables) + { + if (Var.VarName == FName(*Variable)) + { + Var.VarType = NewPinType; + break; + } + } + + // Save + bool bSaved = MCPUtils::SaveBlueprintPackage(BP); + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Variable type changed, save %s"), + bSaved ? TEXT("succeeded") : TEXT("failed")); + + // Return updated variable state + TSharedRef UpdatedVar = MakeShared(); + for (const FBPVariableDescription& Var : BP->NewVariables) + { + if (Var.VarName == FName(*Variable)) + { + 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; + } + } + + Result->SetBoolField(TEXT("success"), true); + Result->SetStringField(TEXT("blueprint"), Blueprint); + Result->SetStringField(TEXT("variable"), Variable); + Result->SetStringField(TEXT("newType"), NewType); + Result->SetStringField(TEXT("typeCategory"), ResolvedTypeCategory); + Result->SetBoolField(TEXT("saved"), bSaved); + Result->SetObjectField(TEXT("updatedVariable"), UpdatedVar); + Result->SetArrayField(TEXT("affectedNodes"), AffectedNodes); + } +}; + +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + +UCLASS(meta=(ToolName="add_blueprint_variable")) +class UMCPHandler_AddVariable : public UObject, public IMCPHandler +{ + GENERATED_BODY() + +public: + UPROPERTY(meta=(Description="Blueprint name or package path")) + FString Blueprint; + + UPROPERTY(meta=(Description="Name of the new variable")) + FString VariableName; + + UPROPERTY(meta=(Description="Type of the new variable")) + FString VariableType; + + UPROPERTY(meta=(Optional, Description="Category to assign the variable to")) + FString Category; + + UPROPERTY(meta=(Optional, Description="If true, make the variable an array")) + bool IsArray = false; + + UPROPERTY(meta=(Optional, Description="Default value for the variable")) + FString DefaultValue; + + virtual FString GetDescription() const override + { + return TEXT("Add a new member variable to a Blueprint."); + } + + virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override + { + // Load Blueprint + FString LoadError; + UBlueprint* BP = UMCPAssetFinder::LoadBlueprintOrLevelBlueprint(Blueprint, LoadError); + if (!BP) + { + return MCPUtils::MakeErrorJson(Result, LoadError); + } + + // Check for duplicate variable name + FName VarFName(*VariableName); + for (const FBPVariableDescription& Var : BP->NewVariables) + { + if (Var.VarName == VarFName) + { + return MCPUtils::MakeErrorJson(Result, FString::Printf( + TEXT("Variable '%s' already exists in Blueprint '%s'"), *VariableName, *Blueprint)); + } + } + + // Resolve the type using the shared helper + FEdGraphPinType PinType; + if (!MCPUtils::ResolveTypeFromString(VariableType, PinType, Result)) + return; + + // Set container type for arrays + if (IsArray) + { + PinType.ContainerType = EPinContainerType::Array; + } + + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Adding variable '%s' (type=%s, array=%s) to Blueprint '%s'"), + *VariableName, *VariableType, IsArray ? TEXT("true") : TEXT("false"), *Blueprint); + + // Add the variable using the editor utility function + bool bSuccess = FBlueprintEditorUtils::AddMemberVariable(BP, VarFName, PinType, DefaultValue); + if (!bSuccess) + { + return MCPUtils::MakeErrorJson(Result, 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 = MCPUtils::SaveBlueprintPackage(BP); + + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Added variable '%s' to '%s' (saved: %s)"), + *VariableName, *Blueprint, bSaved ? TEXT("true") : TEXT("false")); + + Result->SetBoolField(TEXT("success"), true); + Result->SetStringField(TEXT("blueprint"), Blueprint); + Result->SetStringField(TEXT("variableName"), VariableName); + Result->SetStringField(TEXT("variableType"), VariableType); + if (!Category.IsEmpty()) + { + Result->SetStringField(TEXT("category"), Category); + } + Result->SetBoolField(TEXT("isArray"), IsArray); + Result->SetBoolField(TEXT("saved"), bSaved); + } +}; + +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + +UCLASS(meta=(ToolName="remove_blueprint_variable")) +class UMCPHandler_RemoveVariable : public UObject, public IMCPHandler +{ + GENERATED_BODY() + +public: + UPROPERTY(meta=(Description="Blueprint name or package path")) + FString Blueprint; + + UPROPERTY(meta=(Description="Name of the variable to remove")) + FString VariableName; + + virtual FString GetDescription() const override + { + return TEXT("Remove a member variable from a Blueprint."); + } + + virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override + { + // Load Blueprint + FString LoadError; + UBlueprint* BP = UMCPAssetFinder::LoadBlueprintOrLevelBlueprint(Blueprint, LoadError); + if (!BP) + { + return MCPUtils::MakeErrorJson(Result, 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> AvailVars; + for (const FBPVariableDescription& Var : BP->NewVariables) + { + AvailVars.Add(MakeShared(Var.VarName.ToString())); + } + + MCPUtils::MakeErrorJson(Result, FString::Printf( + TEXT("Variable '%s' not found in Blueprint '%s'"), *VariableName, *Blueprint)); + Result->SetArrayField(TEXT("availableVariables"), AvailVars); + return; + } + + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Removing variable '%s' from Blueprint '%s'"), + *VariableName, *Blueprint); + + // Use the editor utility to remove the variable (also cleans up Get/Set nodes) + FBlueprintEditorUtils::RemoveMemberVariable(BP, VarFName); + + FBlueprintEditorUtils::MarkBlueprintAsStructurallyModified(BP); + bool bSaved = MCPUtils::SaveBlueprintPackage(BP); + + UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Removed variable '%s' from '%s' (saved: %s)"), + *VariableName, *Blueprint, bSaved ? TEXT("true") : TEXT("false")); + + Result->SetBoolField(TEXT("success"), true); + Result->SetStringField(TEXT("blueprint"), Blueprint); + Result->SetStringField(TEXT("variableName"), VariableName); + Result->SetBoolField(TEXT("saved"), bSaved); + } +}; + +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + +UCLASS(meta=(ToolName="set_blueprint_variable_metadata")) +class UMCPHandler_SetVariableMetadata : public UObject, public IMCPHandler +{ + GENERATED_BODY() + +public: + UPROPERTY(meta=(Description="Blueprint name or package path")) + FString Blueprint; + + UPROPERTY(meta=(Description="Name of the variable to modify")) + FString Variable; + + UPROPERTY(meta=(Optional, Description="Category to assign the variable to")) + FString Category; + + UPROPERTY(meta=(Optional, Description="Tooltip text for the variable")) + FString Tooltip; + + UPROPERTY(meta=(Optional, Description="Replication mode: none, replicated, or repNotify")) + FString Replication; + + UPROPERTY(meta=(Optional, Description="If true, expose this variable on spawn")) + bool ExposeOnSpawn = false; + + UPROPERTY(meta=(Optional, Description="If true, mark the variable as private")) + bool IsPrivate = false; + + UPROPERTY(meta=(Optional, Description="Editability mode: editAnywhere, editDefaultsOnly, editInstanceOnly, or none")) + FString Editability; + + virtual FString GetDescription() const override + { + return TEXT("Set variable metadata properties such as category, tooltip, " + "replication, editability, and visibility flags."); + } + + virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override + { + // Load Blueprint + FString LoadError; + UBlueprint* BP = UMCPAssetFinder::LoadBlueprintOrLevelBlueprint(Blueprint, LoadError); + if (!BP) + { + return MCPUtils::MakeErrorJson(Result, LoadError); + } + + // Find the variable + FName VarFName(*Variable); + FBPVariableDescription* VarDesc = nullptr; + for (FBPVariableDescription& Var : BP->NewVariables) + { + if (Var.VarName == VarFName) + { + VarDesc = &Var; + break; + } + } + + if (!VarDesc) + { + TArray> AvailableVars; + for (const FBPVariableDescription& Var : BP->NewVariables) + { + AvailableVars.Add(MakeShared(Var.VarName.ToString())); + } + MCPUtils::MakeErrorJson(Result, FString::Printf( + TEXT("Variable '%s' not found in Blueprint '%s'"), *Variable, *Blueprint)); + Result->SetArrayField(TEXT("availableVariables"), AvailableVars); + return; + } + + TArray> Changes; + + // Category + if (Json->HasField(TEXT("category"))) + { + FString OldCategory = VarDesc->Category.ToString(); + VarDesc->Category = FText::FromString(Category); + FBlueprintEditorUtils::SetBlueprintVariableCategory(BP, VarFName, nullptr, FText::FromString(Category)); + + TSharedRef Change = MakeShared(); + Change->SetStringField(TEXT("field"), TEXT("category")); + Change->SetStringField(TEXT("oldValue"), OldCategory); + Change->SetStringField(TEXT("newValue"), Category); + Changes.Add(MakeShared(Change)); + } + + // Tooltip + if (Json->HasField(TEXT("tooltip"))) + { + FString OldTooltip; + FBlueprintEditorUtils::GetBlueprintVariableMetaData(BP, VarFName, nullptr, TEXT("tooltip"), OldTooltip); + FBlueprintEditorUtils::SetBlueprintVariableMetaData(BP, VarFName, nullptr, TEXT("tooltip"), Tooltip); + + TSharedRef Change = MakeShared(); + Change->SetStringField(TEXT("field"), TEXT("tooltip")); + Change->SetStringField(TEXT("oldValue"), OldTooltip); + Change->SetStringField(TEXT("newValue"), Tooltip); + Changes.Add(MakeShared(Change)); + } + + // Replication + if (Json->HasField(TEXT("replication"))) + { + uint64 OldFlags = VarDesc->PropertyFlags; + + if (Replication == TEXT("none")) + { + VarDesc->PropertyFlags &= ~CPF_Net; + VarDesc->PropertyFlags &= ~CPF_RepNotify; + VarDesc->RepNotifyFunc = NAME_None; + } + else if (Replication == TEXT("replicated")) + { + VarDesc->PropertyFlags |= CPF_Net; + VarDesc->PropertyFlags &= ~CPF_RepNotify; + VarDesc->RepNotifyFunc = NAME_None; + } + else if (Replication == TEXT("repNotify")) + { + VarDesc->PropertyFlags |= CPF_Net | CPF_RepNotify; + // Auto-generate RepNotify function name + VarDesc->RepNotifyFunc = FName(*FString::Printf(TEXT("OnRep_%s"), *Variable)); + } + else + { + return MCPUtils::MakeErrorJson(Result, FString::Printf( + TEXT("Invalid replication value '%s'. Valid: none, replicated, repNotify"), *Replication)); + } + + TSharedRef Change = MakeShared(); + Change->SetStringField(TEXT("field"), TEXT("replication")); + Change->SetStringField(TEXT("newValue"), Replication); + Changes.Add(MakeShared(Change)); + } + + // ExposeOnSpawn + if (Json->HasField(TEXT("exposeOnSpawn"))) + { + bool bOld = (VarDesc->PropertyFlags & CPF_ExposeOnSpawn) != 0; + if (ExposeOnSpawn) + VarDesc->PropertyFlags |= CPF_ExposeOnSpawn; + else + VarDesc->PropertyFlags &= ~CPF_ExposeOnSpawn; + + TSharedRef Change = MakeShared(); + Change->SetStringField(TEXT("field"), TEXT("exposeOnSpawn")); + Change->SetStringField(TEXT("oldValue"), bOld ? TEXT("true") : TEXT("false")); + Change->SetStringField(TEXT("newValue"), ExposeOnSpawn ? TEXT("true") : TEXT("false")); + Changes.Add(MakeShared(Change)); + } + + // isPrivate + if (Json->HasField(TEXT("isPrivate"))) + { + bool bOld = (VarDesc->PropertyFlags & CPF_DisableEditOnInstance) != 0; + // In UE5, "private" for Blueprint variables is represented via metadata + FBlueprintEditorUtils::SetBlueprintVariableMetaData(BP, VarFName, nullptr, + TEXT("BlueprintPrivate"), IsPrivate ? TEXT("true") : TEXT("false")); + + TSharedRef Change = MakeShared(); + Change->SetStringField(TEXT("field"), TEXT("isPrivate")); + Change->SetStringField(TEXT("oldValue"), bOld ? TEXT("true") : TEXT("false")); + Change->SetStringField(TEXT("newValue"), IsPrivate ? TEXT("true") : TEXT("false")); + Changes.Add(MakeShared(Change)); + } + + // Editability (EditAnywhere, EditDefaultsOnly, EditInstanceOnly) + if (Json->HasField(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 MCPUtils::MakeErrorJson(Result, FString::Printf( + TEXT("Invalid editability value '%s'. Valid: editAnywhere, editDefaultsOnly, editInstanceOnly, none"), + *Editability)); + } + + TSharedRef Change = MakeShared(); + Change->SetStringField(TEXT("field"), TEXT("editability")); + Change->SetStringField(TEXT("newValue"), Editability); + Changes.Add(MakeShared(Change)); + } + + if (Changes.Num() == 0) + { + return MCPUtils::MakeErrorJson(Result, 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"), + *Blueprint, *Variable, Changes.Num()); + + FBlueprintEditorUtils::MarkBlueprintAsStructurallyModified(BP); + bool bSaved = MCPUtils::SaveBlueprintPackage(BP); + + Result->SetBoolField(TEXT("success"), true); + Result->SetStringField(TEXT("blueprint"), Blueprint); + Result->SetStringField(TEXT("variable"), Variable); + Result->SetArrayField(TEXT("changes"), Changes); + Result->SetBoolField(TEXT("saved"), bSaved); + } +}; diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPServer.cpp b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPServer.cpp index c32c0c4c..a866b431 100644 --- a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPServer.cpp +++ b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPServer.cpp @@ -419,23 +419,6 @@ void FMCPServer::BuildCachedToolsList() ToolsArray.Add(MakeShared(HandlerClassToToolSchema(KV.Value))); } - // Old-style handlers: minimal schema (just the tool name) - for (const auto& KV : HandlerMap) - { - // Skip if already registered as new-style - if (MCPHandlerRegistry.Contains(KV.Key)) continue; - - TSharedRef Tool = MakeShared(); - Tool->SetStringField(TEXT("name"), KV.Key); - Tool->SetStringField(TEXT("description"), FString::Printf(TEXT("Tool: %s"), *KV.Key)); - - TSharedRef InputSchema = MakeShared(); - InputSchema->SetStringField(TEXT("type"), TEXT("object")); - Tool->SetObjectField(TEXT("inputSchema"), InputSchema); - - ToolsArray.Add(MakeShared(Tool)); - } - CachedToolsList = MakeShared(); CachedToolsList->SetArrayField(TEXT("tools"), ToolsArray); } @@ -517,21 +500,6 @@ void FMCPServer::DispatchToolCall(const FString& ToolName, const FJsonObject* Pa GEditor->EndTransaction(); } } - else if (FRequestHandler* Handler = HandlerMap.Find(ToolName)) - { - const bool bIsMutation = MutationEndpoints.Contains(ToolName); - if (bIsMutation && GEditor) - { - GEditor->BeginTransaction(FText::FromString(FString::Printf(TEXT("BlueprintMCP: %s"), *ToolName))); - } - - (*Handler)(Params, Result); - - if (bIsMutation && GEditor) - { - GEditor->EndTransaction(); - } - } else { MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Unknown tool: %s"), *ToolName)); @@ -862,7 +830,6 @@ void FMCPServer::RegisterHandlers() TEXT("add_function_parameter"), TEXT("add_blueprint_component"), TEXT("remove_blueprint_component"), - TEXT("restore_blueprint_graph_from_snapshot"), TEXT("create_material_asset"), TEXT("set_material_property"), TEXT("add_material_expression"), @@ -875,7 +842,6 @@ void FMCPServer::RegisterHandlers() TEXT("set_material_instance_parameter"), TEXT("reparent_material_instance"), TEXT("create_material_function_asset"), - TEXT("restore_material_graph_from_snapshot"), TEXT("add_anim_state_to_machine"), TEXT("remove_anim_state_from_machine"), TEXT("add_anim_state_transition"), @@ -883,77 +849,6 @@ void FMCPServer::RegisterHandlers() TEXT("set_anim_state_animation"), }; - // All handlers have uniform signature: void(const FJsonObject&, FJsonObject&) - auto H = [this](const TCHAR* Name, void(FMCPServer::*Fn)(const FJsonObject*, FJsonObject*)) - { - HandlerMap.Add(Name, [this, Fn](const FJsonObject* Json, FJsonObject* Result) { (this->*Fn)(Json, Result); }); - }; - - H(TEXT("list_blueprint_assets"), &FMCPServer::HandleList); - H(TEXT("dump_blueprint"), &FMCPServer::HandleGetBlueprint); - H(TEXT("dump_blueprint_graph"), &FMCPServer::HandleGetGraph); - H(TEXT("search_within_blueprints"), &FMCPServer::HandleSearch); - H(TEXT("find_asset_references"), &FMCPServer::HandleFindReferences); - H(TEXT("test_save_blueprint_package"), &FMCPServer::HandleTestSave); - H(TEXT("search_type_usage_in_blueprints"), &FMCPServer::HandleSearchByType); - H(TEXT("change_blueprint_variable_type"), &FMCPServer::HandleChangeVariableType); - H(TEXT("change_function_parameter_type"), &FMCPServer::HandleChangeFunctionParamType); - H(TEXT("remove_function_parameter"), &FMCPServer::HandleRemoveFunctionParameter); - H(TEXT("get_pin_details"), &FMCPServer::HandleGetPinInfo); - H(TEXT("check_pin_connection_compatibility"), &FMCPServer::HandleCheckPinCompatibility); - H(TEXT("search_unreal_classes"), &FMCPServer::HandleListClasses); - H(TEXT("list_class_functions"), &FMCPServer::HandleListFunctions); - H(TEXT("list_class_properties"), &FMCPServer::HandleListProperties); - H(TEXT("reparent_blueprint"), &FMCPServer::HandleReparentBlueprint); - H(TEXT("create_blueprint_asset"), &FMCPServer::HandleCreateBlueprint); - H(TEXT("create_blueprint_graph"), &FMCPServer::HandleCreateGraph); - H(TEXT("delete_blueprint_graph"), &FMCPServer::HandleDeleteGraph); - H(TEXT("rename_blueprint_graph"), &FMCPServer::HandleRenameGraph); - H(TEXT("add_blueprint_variable"), &FMCPServer::HandleAddVariable); - H(TEXT("remove_blueprint_variable"), &FMCPServer::HandleRemoveVariable); - H(TEXT("set_blueprint_variable_metadata"), &FMCPServer::HandleSetVariableMetadata); - H(TEXT("add_event_dispatcher"), &FMCPServer::HandleAddEventDispatcher); - H(TEXT("list_event_dispatchers"), &FMCPServer::HandleListEventDispatchers); - H(TEXT("add_function_parameter"), &FMCPServer::HandleAddFunctionParameter); - H(TEXT("add_blueprint_component"), &FMCPServer::HandleAddComponent); - H(TEXT("remove_blueprint_component"), &FMCPServer::HandleRemoveComponent); - H(TEXT("list_blueprint_components"), &FMCPServer::HandleListComponents); - H(TEXT("snapshot_blueprint_graph"), &FMCPServer::HandleSnapshotGraph); - H(TEXT("diff_blueprint_graph_vs_snapshot"), &FMCPServer::HandleDiffGraph); - H(TEXT("restore_blueprint_graph_from_snapshot"), &FMCPServer::HandleRestoreGraph); - H(TEXT("find_pins_disconnected_since_snapshot"), &FMCPServer::HandleFindDisconnectedPins); - H(TEXT("analyze_cpp_rebuild_impact"), &FMCPServer::HandleAnalyzeRebuildImpact); - H(TEXT("list_material_assets"), &FMCPServer::HandleListMaterials); - H(TEXT("dump_material"), &FMCPServer::HandleGetMaterial); - H(TEXT("dump_material_expression_graph"), &FMCPServer::HandleGetMaterialGraph); - H(TEXT("search_within_materials"), &FMCPServer::HandleSearchMaterials); - H(TEXT("dump_material_instance_parameters"),&FMCPServer::HandleGetMaterialInstanceParameters); - H(TEXT("list_material_function_assets"), &FMCPServer::HandleListMaterialFunctions); - H(TEXT("dump_material_function"), &FMCPServer::HandleGetMaterialFunction); - H(TEXT("describe_material_in_english"), &FMCPServer::HandleDescribeMaterial); - H(TEXT("find_material_references"), &FMCPServer::HandleFindMaterialReferences); - H(TEXT("create_material_asset"), &FMCPServer::HandleCreateMaterial); - H(TEXT("set_material_property"), &FMCPServer::HandleSetMaterialProperty); - H(TEXT("add_material_expression"), &FMCPServer::HandleAddMaterialExpression); - H(TEXT("delete_material_expression"), &FMCPServer::HandleDeleteMaterialExpression); - H(TEXT("connect_material_expression_pins"), &FMCPServer::HandleConnectMaterialPins); - H(TEXT("disconnect_material_expression_pin"), &FMCPServer::HandleDisconnectMaterialPin); - H(TEXT("set_material_expression_property"), &FMCPServer::HandleSetExpressionValue); - H(TEXT("set_material_expression_position"), &FMCPServer::HandleMoveMaterialExpression); - H(TEXT("create_material_instance_asset"), &FMCPServer::HandleCreateMaterialInstance); - H(TEXT("set_material_instance_parameter"), &FMCPServer::HandleSetMaterialInstanceParameter); - H(TEXT("reparent_material_instance"), &FMCPServer::HandleReparentMaterialInstance); - H(TEXT("create_material_function_asset"), &FMCPServer::HandleCreateMaterialFunction); - H(TEXT("snapshot_material_expression_graph"), &FMCPServer::HandleSnapshotMaterialGraph); - H(TEXT("diff_material_graph_vs_snapshot"), &FMCPServer::HandleDiffMaterialGraph); - H(TEXT("restore_material_graph_from_snapshot"), &FMCPServer::HandleRestoreMaterialGraph); - H(TEXT("compile_material"), &FMCPServer::HandleValidateMaterial); - H(TEXT("add_anim_state_to_machine"), &FMCPServer::HandleAddAnimState); - H(TEXT("remove_anim_state_from_machine"), &FMCPServer::HandleRemoveAnimState); - H(TEXT("add_anim_state_transition"), &FMCPServer::HandleAddAnimTransition); - H(TEXT("set_anim_transition_rule"), &FMCPServer::HandleSetTransitionRule); - H(TEXT("set_anim_state_animation"), &FMCPServer::HandleSetStateAnimation); - H(TEXT("set_anim_state_blend_space"), &FMCPServer::HandleSetStateBlendSpace); } void FMCPServer::BuildMCPHandlerRegistry() diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Public/MCPServer.h b/Plugins/BlueprintMCP/Source/BlueprintMCP/Public/MCPServer.h index 5222a654..bf31d7d2 100644 --- a/Plugins/BlueprintMCP/Source/BlueprintMCP/Public/MCPServer.h +++ b/Plugins/BlueprintMCP/Source/BlueprintMCP/Public/MCPServer.h @@ -47,9 +47,7 @@ public: private: // ----- Tool dispatch ----- - using FRequestHandler = TFunction; - TMap HandlerMap; // old-style handlers - TMap MCPHandlerRegistry; // new-style: tool name -> UMCPHandler subclass + TMap MCPHandlerRegistry; // tool name -> UMCPHandler subclass TSet MutationEndpoints; void RegisterHandlers(); void BuildMCPHandlerRegistry(); @@ -105,126 +103,7 @@ private: FCriticalSection Mutex; TArray> PendingMessages; bool bShuttingDown = false; - - // ----- Request handlers (read-only) ----- - void HandleList(const FJsonObject* Json, FJsonObject* Result); - void HandleGetBlueprint(const FJsonObject* Json, FJsonObject* Result); - void HandleGetGraph(const FJsonObject* Json, FJsonObject* Result); - void HandleSearch(const FJsonObject* Json, FJsonObject* Result); - void HandleFindReferences(const FJsonObject* Json, FJsonObject* Result); - void HandleSearchByType(const FJsonObject* Json, FJsonObject* Result); - - // ----- Request handlers (write) ----- - void HandleChangeVariableType(const FJsonObject* Json, FJsonObject* Result); - void HandleChangeFunctionParamType(const FJsonObject* Json, FJsonObject* Result); - void HandleRemoveFunctionParameter(const FJsonObject* Json, FJsonObject* Result); - - // ----- Pin introspection (read-only) ----- - void HandleGetPinInfo(const FJsonObject* Json, FJsonObject* Result); - void HandleCheckPinCompatibility(const FJsonObject* Json, FJsonObject* Result); - - // ----- Class/function discovery (read-only) ----- - void HandleListClasses(const FJsonObject* Json, FJsonObject* Result); - void HandleListFunctions(const FJsonObject* Json, FJsonObject* Result); - void HandleListProperties(const FJsonObject* Json, FJsonObject* Result); - - // ----- Reparent ----- - void HandleReparentBlueprint(const FJsonObject* Json, FJsonObject* Result); - - // ----- Create ----- - void HandleCreateBlueprint(const FJsonObject* Json, FJsonObject* Result); - void HandleCreateGraph(const FJsonObject* Json, FJsonObject* Result); - - // ----- Graph manipulation ----- - void HandleDeleteGraph(const FJsonObject* Json, FJsonObject* Result); - void HandleRenameGraph(const FJsonObject* Json, FJsonObject* Result); - - // ----- Variables ----- - void HandleAddVariable(const FJsonObject* Json, FJsonObject* Result); - void HandleRemoveVariable(const FJsonObject* Json, FJsonObject* Result); - void HandleSetVariableMetadata(const FJsonObject* Json, FJsonObject* Result); - - // ----- Event Dispatchers ----- - void HandleAddEventDispatcher(const FJsonObject* Json, FJsonObject* Result); - void HandleListEventDispatchers(const FJsonObject* Json, FJsonObject* Result); - - // ----- Function Parameters ----- - void HandleAddFunctionParameter(const FJsonObject* Json, FJsonObject* Result); - - // ----- Components ----- - void HandleAddComponent(const FJsonObject* Json, FJsonObject* Result); - void HandleRemoveComponent(const FJsonObject* Json, FJsonObject* Result); - void HandleListComponents(const FJsonObject* Json, FJsonObject* Result); - - // ----- Diagnostic ----- - void HandleTestSave(const FJsonObject* Json, FJsonObject* Result); - - // ----- Snapshot / Safety tools (write) ----- - void HandleSnapshotGraph(const FJsonObject* Json, FJsonObject* Result); - void HandleDiffGraph(const FJsonObject* Json, FJsonObject* Result); - void HandleRestoreGraph(const FJsonObject* Json, FJsonObject* Result); - void HandleFindDisconnectedPins(const FJsonObject* Json, FJsonObject* Result); - void HandleAnalyzeRebuildImpact(const FJsonObject* Json, FJsonObject* Result); - - // ----- Material read-only handlers (Phase 1) ----- - void HandleListMaterials(const FJsonObject* Json, FJsonObject* Result); - void HandleGetMaterial(const FJsonObject* Json, FJsonObject* Result); - void HandleGetMaterialGraph(const FJsonObject* Json, FJsonObject* Result); - void HandleDescribeMaterial(const FJsonObject* Json, FJsonObject* Result); - void HandleSearchMaterials(const FJsonObject* Json, FJsonObject* Result); - void HandleFindMaterialReferences(const FJsonObject* Json, FJsonObject* Result); - - // ----- Material mutation handlers (Phase 2) ----- - void HandleCreateMaterial(const FJsonObject* Json, FJsonObject* Result); - void HandleSetMaterialProperty(const FJsonObject* Json, FJsonObject* Result); - void HandleAddMaterialExpression(const FJsonObject* Json, FJsonObject* Result); - void HandleDeleteMaterialExpression(const FJsonObject* Json, FJsonObject* Result); - void HandleConnectMaterialPins(const FJsonObject* Json, FJsonObject* Result); - void HandleDisconnectMaterialPin(const FJsonObject* Json, FJsonObject* Result); - void HandleSetExpressionValue(const FJsonObject* Json, FJsonObject* Result); - void HandleMoveMaterialExpression(const FJsonObject* Json, FJsonObject* Result); - - // ----- Material instance handlers (Phase 3) ----- - void HandleCreateMaterialInstance(const FJsonObject* Json, FJsonObject* Result); - void HandleSetMaterialInstanceParameter(const FJsonObject* Json, FJsonObject* Result); - void HandleGetMaterialInstanceParameters(const FJsonObject* Json, FJsonObject* Result); - void HandleReparentMaterialInstance(const FJsonObject* Json, FJsonObject* Result); - - // ----- Material function handlers (Phase 4) ----- - void HandleListMaterialFunctions(const FJsonObject* Json, FJsonObject* Result); - void HandleGetMaterialFunction(const FJsonObject* Json, FJsonObject* Result); - void HandleCreateMaterialFunction(const FJsonObject* Json, FJsonObject* Result); - - // ----- Material validation ----- - void HandleValidateMaterial(const FJsonObject* Json, FJsonObject* Result); - - // ----- Material snapshot/diff/restore (Phase 5) ----- - void HandleSnapshotMaterialGraph(const FJsonObject* Json, FJsonObject* Result); - void HandleDiffMaterialGraph(const FJsonObject* Json, FJsonObject* Result); - void HandleRestoreMaterialGraph(const FJsonObject* Json, FJsonObject* Result); - - // ----- Animation Blueprint handlers (state machine, still old-style) ----- - void HandleAddAnimState(const FJsonObject* Json, FJsonObject* Result); - void HandleRemoveAnimState(const FJsonObject* Json, FJsonObject* Result); - void HandleAddAnimTransition(const FJsonObject* Json, FJsonObject* Result); - void HandleSetTransitionRule(const FJsonObject* Json, FJsonObject* Result); - void HandleSetStateAnimation(const FJsonObject* Json, FJsonObject* Result); - void HandleSetStateBlendSpace(const FJsonObject* Json, FJsonObject* Result); - -public: - // ----- Snapshot storage ----- - TMap Snapshots; - TMap 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); }; -// Transitional alias — old-style handlers use this to access the server instance. using FBlueprintMCPServer = FMCPServer; -using MCPHelper = FMCPServer; + diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Public/MCPUtils.h b/Plugins/BlueprintMCP/Source/BlueprintMCP/Public/MCPUtils.h index 6378f316..03aa2cc1 100644 --- a/Plugins/BlueprintMCP/Source/BlueprintMCP/Public/MCPUtils.h +++ b/Plugins/BlueprintMCP/Source/BlueprintMCP/Public/MCPUtils.h @@ -15,39 +15,6 @@ class UAnimationStateMachineGraph; class UAnimStateNode; class UAnimStateTransitionNode; -// ----- 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 Nodes; - TArray Connections; -}; - -struct FGraphSnapshot -{ - FString SnapshotId; - FString BlueprintName; - FString BlueprintPath; - FDateTime CreatedAt; - TMap Graphs; // graphName -> data -}; - // ----- Log capture ----- class FLogCaptureOutputDevice : public FOutputDevice diff --git a/refactor.md b/refactor.md new file mode 100644 index 00000000..4ca74554 --- /dev/null +++ b/refactor.md @@ -0,0 +1,194 @@ + +# Handler Refactor Instructions + +Plugins/BlueprintMCP/Source/BlueprintMCP contains an MCP server that allows +Claude Code to control Unreal functions. MCP servers accept different +commands; each command has a "handler." + +We are converting all handlers from an old style to a new style. In the old +style, each handler is a method of FMCPServer. In the new style, each +handler is a UCLASS derived from IMCPHandler. You will be assigned one +source file to convert. **Only modify that one file.** + +## Step 1: Study the Existing Code + +Before editing, read these files carefully: + +- `MCPHandler.h` — the IMCPHandler interface and marker structs +- `MCPUtils.h` — utility functions available to handlers +- `MCPAssetFinder.h` — asset lookup helpers (FindAsset, LoadAsset, etc.) +- `MCPHandlers_Interfaces.h` — a clean example of new-style handlers +- `MCPHandlers_Validation.h` — another example (shows Optional params, bool, int32) +- `MCPHandlers_AnimMutation.h` — another example (shows FMCPJsonArray) + +All of these are in `Plugins/BlueprintMCP/Source/BlueprintMCP/`. + +## Step 2: Convert Your File + +Each old-style handler function becomes a new-style UCLASS. The mechanical +steps are: + +### 2a. Change the file header + +Replace the old includes with this pattern: + +```cpp +#pragma once + +#include "CoreMinimal.h" +#include "MCPHandler.h" +#include "MCPAssetFinder.h" +#include "MCPUtils.h" +// ... any other includes the handlers actually need ... +#include "MCPHandlers_YourFile.generated.h" +``` + +Remove `#include "MCPServer.h"` — new-style handlers don't need it. + +Remove any includes that are no longer used after conversion. Keep only +what the handler bodies actually reference. + +### 2b. Wrap each handler in a UCLASS + +Each old-style function like this: + +```cpp +void FMCPServer::HandleFoo(const FJsonObject* Json, FJsonObject* Result) +{ + FString Name = Json->GetStringField(TEXT("name")); + // ... body ... +} +``` + +Becomes: + +```cpp +UCLASS(meta=(ToolName="the_mcp_tool_name")) +class UMCPHandler_Foo : public UObject, public IMCPHandler +{ + GENERATED_BODY() + +public: + UPROPERTY(meta=(Description="Human-readable description of this parameter")) + FString Name; + + virtual FString GetDescription() const override + { + return TEXT("Human-readable description of what this tool does."); + } + + virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override + { + // ... body (minus the parameter extraction) ... + } +}; +``` + +### 2c. Convert parameters to UPROPERTY fields + +- Each `Json->GetStringField(TEXT("foo"))` at the top of the handler becomes + a `UPROPERTY` field on the class. The framework populates these + automatically before calling `Handle()`. + +- **Naming rule**: The UPROPERTY field name's first character gets + lowercased to produce the JSON key. So `FString Blueprint;` maps to + JSON key `"blueprint"`. `FString PackagePath;` maps to `"packagePath"`. + Choose field names so that this automatic mapping produces the correct + JSON key (i.e. the same key the old code was reading). + +- **Required vs optional**: Fields are required by default. Add + `meta=(Optional, Description="...")` for optional parameters. Optional + fields should have default values (e.g. `bool Foo = false;`, + `int32 Limit = 0;`, `FString Bar;` — empty string is the default). + +- **Supported types**: `FString`, `bool`, `int32`, `float`, `FName`. + For JSON arrays use `FMCPJsonArray` (has `.Array` field of type + `TArray>`). For JSON objects use `FMCPJsonObject` + (has `.Json` field of type `TSharedPtr`). + +- **Remove manual extraction**: Delete the `Json->GetStringField()`, + `Json->GetBoolField()`, etc. lines that read parameters. The UPROPERTY + fields already contain the values. You can still use `Json->HasField()` + to check whether an optional field was actually provided (for cases + where you need to distinguish "not provided" from "provided as default + value"). + +- **Remove manual validation of required fields**: The framework already + returns an error if a required UPROPERTY field is missing from JSON. + Delete checks like `if (Name.IsEmpty()) return MakeErrorJson(...)` for + required FString fields. However, keep validation that checks field + *content* (e.g. "must start with /Game"). + +### 2d. Avoid UPROPERTY / local variable name conflicts + +If the old code has a local variable with the same name as your new +UPROPERTY field (e.g. UPROPERTY `FString Blueprint;` and local +`UBlueprint* Blueprint`), rename the local variable. Convention: +append `Obj` — e.g. `UBlueprint* BlueprintObj`, `USkeleton* SkeletonObj`. + +### 2e. Tool name + +The `ToolName` in the UCLASS meta must exactly match the tool name string +that was used in the old-style `H(TEXT("tool_name"), ...)` registration +in MCPServer.cpp. If you're unsure, search MCPServer.cpp for the old +handler function name to find the tool name. + +### 2f. GetDescription() + +Write a concise 1-2 sentence description of what the tool does. This is +shown to the LLM that calls the tool. + +### 2g. Separator between classes + +Put this separator between each UCLASS: + +```cpp +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +``` + +## Step 3: Rename the File + +After conversion, rename the file from `.cpp` to `.h`. The file is now a +header — UHT will process the GENERATED_BODY() macros during build. + +## What NOT to Do + +- **Do NOT modify MCPServer.h or MCPServer.cpp.** We will clean up old + registrations separately. +- **Do NOT modify MCPHandlers.cpp** (the include aggregator). We will add + the new include separately. +- **Do NOT add `#include "MCPServer.h"`** — new-style handlers are + self-registering via UHT reflection and don't need it. +- **Do NOT change the handler logic.** Convert the structure, not the + behavior. This is a mechanical refactor. If you see a bug that can be + fixed within your file (e.g. a typo), fix it. But if you see a bug that + would require editing other files to fix, leave the code as-is and insert + a prominent comment block like this: + ```cpp + // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + // BUG: + // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + ``` + Another agent will deal with it later. +- **Do NOT rename JSON output fields.** The tool's output format must stay + identical. +- **Do NOT add or remove handlers.** Convert exactly what's there. + +## Files to Convert + +Each of these `.cpp` files contains old-style handlers to convert: + +1. `MCPHandlers_Read.cpp` +2. `MCPHandlers_Discovery.cpp` +3. `MCPHandlers_Graphs.cpp` +4. `MCPHandlers_Variables.cpp` +5. `MCPHandlers_Params.cpp` +6. `MCPHandlers_Dispatchers.cpp` +7. `MCPHandlers_Components.cpp` +8. `MCPHandlers_Snapshot.cpp` +9. `MCPHandlers_MaterialRead.cpp` +10. `MCPHandlers_MaterialMutation.cpp` +11. `MCPHandlers_MaterialInstance.cpp` +12. `MCPHandlers_StateMachine.cpp`