From 0e79b02307228eec61a5f71ba86caa44b8ab1cd3 Mon Sep 17 00:00:00 2001 From: jyelon Date: Tue, 10 Mar 2026 20:15:59 -0400 Subject: [PATCH] More work on MCP --- .clangd-query.pid | 1 + Content/Testing/M_Test.uasset | 3 + .../Private/Handlers/RemainingIssues.md | 8 + .../Private/Handlers/UMCPHandler_DumpGraphs.h | 22 +- .../UMCPHandler_DumpMaterialExpressionGraph.h | 82 ------ .../Handlers/UMCPHandler_DumpProperties.h | 77 ++++++ .../UMCPHandler_ListBlueprintAssets.h | 4 +- .../UMCPHandler_ListClassProperties.h | 4 +- .../UMCPHandler_ListOpenAssetEditors.h | 49 ++++ .../Handlers/UMCPHandler_OpenAssetEditor.h | 46 ++++ .../Handlers/UMCPHandler_SearchAssets.h | 12 +- .../UMCPHandler_SearchSpawnableNodeTypes.h | 44 ++- .../UMCPHandler_SearchTypeUsageInBlueprints.h | 4 +- .../UMCPHandler_SearchUnrealClasses.h | 4 +- .../Handlers/UMCPHandler_SetProperties.h | 99 +++++++ .../Handlers/UMCPHandler_ShowCommands.h | 9 + ...odesInGraph.h => UMCPHandler_SpawnNodes.h} | 36 +-- .../BlueprintMCP/Private/MCPAssetFinder.cpp | 14 +- .../BlueprintMCP/Private/MCPFetcher.cpp | 23 +- .../BlueprintMCP/Private/MCPHandlers.cpp | 7 +- .../Source/BlueprintMCP/Private/MCPUtils.cpp | 253 ++++++++++++++---- .../Source/BlueprintMCP/Public/MCPUtils.h | 31 ++- tools/split-handlers.py | 92 ------- 23 files changed, 617 insertions(+), 307 deletions(-) create mode 100644 .clangd-query.pid create mode 100644 Content/Testing/M_Test.uasset delete mode 100644 Plugins/BlueprintMCP/Source/BlueprintMCP/Private/Handlers/UMCPHandler_DumpMaterialExpressionGraph.h create mode 100644 Plugins/BlueprintMCP/Source/BlueprintMCP/Private/Handlers/UMCPHandler_DumpProperties.h create mode 100644 Plugins/BlueprintMCP/Source/BlueprintMCP/Private/Handlers/UMCPHandler_ListOpenAssetEditors.h create mode 100644 Plugins/BlueprintMCP/Source/BlueprintMCP/Private/Handlers/UMCPHandler_OpenAssetEditor.h create mode 100644 Plugins/BlueprintMCP/Source/BlueprintMCP/Private/Handlers/UMCPHandler_SetProperties.h rename Plugins/BlueprintMCP/Source/BlueprintMCP/Private/Handlers/{UMCPHandler_SpawnNodesInGraph.h => UMCPHandler_SpawnNodes.h} (66%) delete mode 100644 tools/split-handlers.py diff --git a/.clangd-query.pid b/.clangd-query.pid new file mode 100644 index 00000000..88ebaeb7 --- /dev/null +++ b/.clangd-query.pid @@ -0,0 +1 @@ +540424 \ No newline at end of file diff --git a/Content/Testing/M_Test.uasset b/Content/Testing/M_Test.uasset new file mode 100644 index 00000000..3fb7a0b7 --- /dev/null +++ b/Content/Testing/M_Test.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ba018d2795620764573fdd12c9ecaf519b31c11e3c789469ecd9782a3f8d4029 +size 12291 diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/Handlers/RemainingIssues.md b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/Handlers/RemainingIssues.md index 642dd5c2..5950cf91 100644 --- a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/Handlers/RemainingIssues.md +++ b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/Handlers/RemainingIssues.md @@ -54,3 +54,11 @@ is actually fine — but it's a one-way door. recreated during the replacement - **DumpMaterialInstanceParameters** — parent chain lost class type info (Material vs MaterialInstance) + +## Design changes + +- Saving assets is being done at somewhat unpredictable + points. It's not entirely clear that we *should* be + saving things every time an edit is made. It might be + better to have an explicit "Save" MCP command. + diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/Handlers/UMCPHandler_DumpGraphs.h b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/Handlers/UMCPHandler_DumpGraphs.h index 33dfe65d..823e7101 100644 --- a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/Handlers/UMCPHandler_DumpGraphs.h +++ b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/Handlers/UMCPHandler_DumpGraphs.h @@ -7,6 +7,8 @@ #include "BlueprintExporter.h" #include "Engine/Blueprint.h" #include "EdGraph/EdGraph.h" +#include "Materials/Material.h" +#include "MaterialGraph/MaterialGraph.h" #include "UMCPHandler_DumpGraphs.generated.h" @@ -20,13 +22,13 @@ class UMCPHandler_DumpGraphs : public UObject, public IMCPHandler GENERATED_BODY() public: - UPROPERTY(meta=(Description="Path to a blueprint or graph, e.g. /Game/Foo or /Game/Foo,graph:EventGraph")) + UPROPERTY(meta=(Description="Path to a blueprint, material, or graph, e.g. /Game/Foo or /Game/Foo,graph:EventGraph")) FString Path; virtual FString GetDescription() const override { - return TEXT("Dump blueprint graphs as readable text. " - "If given a blueprint, dumps all graphs. If given a specific graph, dumps only that one."); + return TEXT("Dump blueprint or material graphs as readable text. " + "If given a blueprint or material, dumps all graphs. If given a specific graph, dumps only that one."); } virtual void Handle(const FJsonObject* Json, FStringBuilderBase& Result) override @@ -52,7 +54,19 @@ public: return; } - Result.Appendf(TEXT("ERROR: Expected a blueprint or graph, got %s\n"), + if (UMaterial* Mat = Cast(F.Obj)) + { + MCPUtils::EnsureMaterialGraph(Mat); + if (!Mat->MaterialGraph) + { + Result.Append(TEXT("ERROR: Could not build MaterialGraph for this material\n")); + return; + } + EmitGraph(Mat->MaterialGraph, Result); + return; + } + + Result.Appendf(TEXT("ERROR: Expected a blueprint, material, or graph, got %s\n"), *MCPUtils::FormatName(F.Obj->GetClass())); } diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/Handlers/UMCPHandler_DumpMaterialExpressionGraph.h b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/Handlers/UMCPHandler_DumpMaterialExpressionGraph.h deleted file mode 100644 index 0e681567..00000000 --- a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/Handlers/UMCPHandler_DumpMaterialExpressionGraph.h +++ /dev/null @@ -1,82 +0,0 @@ -#pragma once - -#include "CoreMinimal.h" -#include "MCPHandler.h" -#include "MCPAssetFinder.h" -#include "MCPUtils.h" -#include "Materials/Material.h" -#include "Materials/MaterialExpression.h" -#include "MaterialGraph/MaterialGraph.h" -#include "MaterialGraph/MaterialGraphNode.h" -#include "MaterialGraph/MaterialGraphNode_Root.h" -#include "MaterialGraph/MaterialGraphSchema.h" -#include "Kismet2/BlueprintEditorUtils.h" -#include "EdGraph/EdGraph.h" -#include "EdGraph/EdGraphNode.h" -#include "UMCPHandler_DumpMaterialExpressionGraph.generated.h" - - -// --------------------------------------------------------------------------- -// --------------------------------------------------------------------------- -// --------------------------------------------------------------------------- - -UCLASS() -class UMCPHandler_DumpMaterialExpressionGraph : public UObject, public IMCPHandler -{ - GENERATED_BODY() - -public: - UPROPERTY(meta=(Description="Material name or package path")) - FString Material; - - virtual FString GetDescription() const override - { - return TEXT("Dump the expression graph for a material, showing all nodes and connections as readable text."); - } - - virtual void Handle(const FJsonObject* Json, FStringBuilderBase& Result) override - { - MCPAssets Assets; - if (!Assets.Exact(Material).Errors(Result).ENone().ETwo().Load()) return; - UMaterial* Mat = Assets.Object(); - - // Ensure the material graph is built (it's created lazily by the material editor) - MCPUtils::EnsureMaterialGraph(Mat); - if (!Mat->MaterialGraph) - { - Result.Append(TEXT("ERROR: Could not build MaterialGraph for this material\n")); - return; - } - - Result.Appendf(TEXT("Material: %s\n"), *MCPUtils::FormatName(Mat)); - Result.Appendf(TEXT("Expressions: %d\n\n"), Mat->GetExpressions().Num()); - - UMaterialGraph* Graph = Mat->MaterialGraph; - for (UEdGraphNode* Node : Graph->Nodes) - { - Result.Appendf(TEXT("--- %s ---\n"), *MCPUtils::FormatName(Node)); - - // Show the material expression type for material graph nodes - if (UMaterialGraphNode* MatNode = Cast(Node)) - { - if (MatNode->MaterialExpression) - Result.Appendf(TEXT(" Expression: %s\n"), *MCPUtils::FormatName(MatNode->MaterialExpression)); - } - - // Output pins and their connections - for (UEdGraphPin* Pin : Node->Pins) - { - if (Pin->Direction == EGPD_Output && Pin->LinkedTo.Num() > 0) - { - for (UEdGraphPin* LinkedPin : Pin->LinkedTo) - { - Result.Appendf(TEXT(" %s -> %s.%s\n"), - *MCPUtils::FormatName(Pin), - *MCPUtils::FormatName(LinkedPin->GetOwningNode()), - *MCPUtils::FormatName(LinkedPin)); - } - } - } - } - } -}; diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/Handlers/UMCPHandler_DumpProperties.h b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/Handlers/UMCPHandler_DumpProperties.h new file mode 100644 index 00000000..81b7f8b4 --- /dev/null +++ b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/Handlers/UMCPHandler_DumpProperties.h @@ -0,0 +1,77 @@ +#pragma once + +#include "CoreMinimal.h" +#include "MCPHandler.h" +#include "MCPFetcher.h" +#include "MCPUtils.h" +#include "UMCPHandler_DumpProperties.generated.h" + + +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + +UCLASS() +class UMCPHandler_DumpProperties : public UObject, public IMCPHandler +{ + GENERATED_BODY() + +public: + UPROPERTY(meta=(Description="MCPFetcher path to the object (e.g. /Game/Materials/M_Gold or /Game/Tangibles/TAN_Char,component:Mesh0)")) + FString Path; + + UPROPERTY(meta=(Optional, Description="Substring filter for property names")) + FString Query; + + UPROPERTY(meta=(Optional, Description="Truncate values to 80 characters (default true)")) + bool Truncate = true; + + virtual FString GetDescription() const override + { + return TEXT("List all blueprint-visible properties on an object resolved via MCPFetcher path, " + "showing current values and which properties are editable."); + } + + virtual void Handle(const FJsonObject* Json, FStringBuilderBase& Result) override + { + // Resolve the path to an object. + MCPFetcher F(Result); + UObject* Obj = F.Walk(Path).Cast(); + if (!Obj) return; + + // Get the editable template (e.g. Blueprint → CDO). + UObject* Template = MCPUtils::GetEditableTemplate(Obj, Result); + if (!Template) return; + + TArray Props = MCPUtils::BlueprintVisibleProperties(Template, Query); + + UStruct* CurrentOwner = nullptr; + for (FProperty* Prop : Props) + { + FString PropName = MCPUtils::FormatName(Prop); + + // Print section heading when the owning class changes. + UStruct* Owner = Prop->GetOwnerStruct(); + if (Owner != CurrentOwner) + { + CurrentOwner = Owner; + Result.Appendf(TEXT("\nFrom %s:\n\n"), *MCPUtils::FormatName(Owner)); + } + + FString ValueStr = MCPUtils::GetPropertyValueText(Template, Prop); + + if (Truncate && (ValueStr.Len() > 80)) + ValueStr = ValueStr.Left(80) + TEXT("..."); + + bool bEditable = Prop->HasAnyPropertyFlags(CPF_Edit); + Result.Appendf(TEXT(" %s %s %s = %s\n"), + bEditable ? TEXT("editable") : TEXT("readonly"), + *MCPUtils::FormatPropertyType(Prop), + *PropName, + *ValueStr); + } + + if (Props.IsEmpty()) + Result.Append(TEXT(" (no blueprint-visible properties found)\n")); + } +}; diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/Handlers/UMCPHandler_ListBlueprintAssets.h b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/Handlers/UMCPHandler_ListBlueprintAssets.h index 6699122b..4ff990ad 100644 --- a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/Handlers/UMCPHandler_ListBlueprintAssets.h +++ b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/Handlers/UMCPHandler_ListBlueprintAssets.h @@ -20,7 +20,7 @@ class UMCPHandler_ListBlueprintAssets : public UObject, public IMCPHandler public: UPROPERTY(meta=(Optional, Description="Substring filter for blueprint name or path")) - FString Filter; + FString Query; UPROPERTY(meta=(Optional, Description="Filter by parent class name (exact match, case-insensitive)")) FString ParentClass; @@ -39,7 +39,7 @@ public: virtual void Handle(const FJsonObject* Json, FStringBuilderBase& Result) override { MCPAssets Assets; - Assets.NoScans().Substring(Filter).Limit(500).Errors(Result); + Assets.NoScans().Substring(Query).Limit(500).Errors(Result); if (IncludeRegular) Assets.Scan(); if (IncludeLevel) Assets.Scan(); Assets.Info(); diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/Handlers/UMCPHandler_ListClassProperties.h b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/Handlers/UMCPHandler_ListClassProperties.h index fc919e32..45424cde 100644 --- a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/Handlers/UMCPHandler_ListClassProperties.h +++ b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/Handlers/UMCPHandler_ListClassProperties.h @@ -20,7 +20,7 @@ public: FString ClassName; UPROPERTY(meta=(Optional, Description="Substring filter for property names")) - FString Filter; + FString Query; virtual FString GetDescription() const override { @@ -46,7 +46,7 @@ public: FString PropName = Prop->GetName(); - if (!Filter.IsEmpty() && !PropName.Contains(Filter, ESearchCase::IgnoreCase)) + if (!Query.IsEmpty() && !PropName.Contains(Query, ESearchCase::IgnoreCase)) continue; // Build compact flags string diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/Handlers/UMCPHandler_ListOpenAssetEditors.h b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/Handlers/UMCPHandler_ListOpenAssetEditors.h new file mode 100644 index 00000000..f1428682 --- /dev/null +++ b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/Handlers/UMCPHandler_ListOpenAssetEditors.h @@ -0,0 +1,49 @@ +#pragma once + +#include "CoreMinimal.h" +#include "MCPHandler.h" +#include "Subsystems/AssetEditorSubsystem.h" +#include "UMCPHandler_ListOpenAssetEditors.generated.h" + + +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + +UCLASS() +class UMCPHandler_ListOpenAssetEditors : public UObject, public IMCPHandler +{ + GENERATED_BODY() + +public: + virtual FString GetDescription() const override + { + return TEXT("List all currently open asset editors, showing which has focus and whether they have unsaved changes."); + } + + virtual void Handle(const FJsonObject* Json, FStringBuilderBase& Result) override + { + UAssetEditorSubsystem* Sub = GEditor->GetEditorSubsystem(); + if (!Sub) + { + Result.Append(TEXT("Error: AssetEditorSubsystem not available\n")); + return; + } + + TArray EditedAssets = Sub->GetAllEditedAssets(); + if (EditedAssets.IsEmpty()) + { + Result.Append(TEXT("No asset editors are open.\n")); + return; + } + + for (UObject* Asset : EditedAssets) + { + bool bDirty = Asset->GetOutermost()->IsDirty(); + + Result.Appendf(TEXT(" %s%s\n"), + bDirty ? TEXT("[unsaved] ") : TEXT(""), + *Asset->GetPathName()); + } + } +}; diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/Handlers/UMCPHandler_OpenAssetEditor.h b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/Handlers/UMCPHandler_OpenAssetEditor.h new file mode 100644 index 00000000..138de601 --- /dev/null +++ b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/Handlers/UMCPHandler_OpenAssetEditor.h @@ -0,0 +1,46 @@ +#pragma once + +#include "CoreMinimal.h" +#include "MCPHandler.h" +#include "MCPFetcher.h" +#include "Subsystems/AssetEditorSubsystem.h" +#include "UMCPHandler_OpenAssetEditor.generated.h" + + +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + +UCLASS() +class UMCPHandler_OpenAssetEditor : public UObject, public IMCPHandler +{ + GENERATED_BODY() + +public: + UPROPERTY(meta=(Description="MCPFetcher path to the asset to open (e.g. /Game/Materials/M_Gold)")) + FString Path; + + virtual FString GetDescription() const override + { + return TEXT("Open an asset in its editor and bring it to focus."); + } + + virtual void Handle(const FJsonObject* Json, FStringBuilderBase& Result) override + { + MCPFetcher F(Result); + UObject* Obj = F.Walk(Path).Cast(); + if (!Obj) return; + + UAssetEditorSubsystem* Sub = GEditor->GetEditorSubsystem(); + if (!Sub) + { + Result.Append(TEXT("Error: AssetEditorSubsystem not available\n")); + return; + } + + if (Sub->OpenEditorForAsset(Obj)) + Result.Appendf(TEXT("Opened editor for %s\n"), *Obj->GetPathName()); + else + Result.Appendf(TEXT("Error: Could not open editor for %s\n"), *Obj->GetPathName()); + } +}; diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/Handlers/UMCPHandler_SearchAssets.h b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/Handlers/UMCPHandler_SearchAssets.h index f1044f73..ccf66406 100644 --- a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/Handlers/UMCPHandler_SearchAssets.h +++ b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/Handlers/UMCPHandler_SearchAssets.h @@ -18,7 +18,7 @@ class UMCPHandler_SearchAssets : public UObject, public IMCPHandler public: UPROPERTY(meta=(Optional, Description="Substring to match against asset package paths")) - FString Search; + FString Query; UPROPERTY(meta=(Optional, Description="Asset class name to filter by, e.g. Blueprint, Material, StaticMesh")) FString Type; @@ -28,14 +28,14 @@ public: virtual FString GetDescription() const override { - return TEXT("Search for assets by name and/or type. At least one of Search or Type must be specified."); + return TEXT("Search for assets by name and/or type. At least one of Query or Type must be specified."); } virtual void Handle(const FJsonObject* Json, FStringBuilderBase& Result) override { - if (Search.IsEmpty() && Type.IsEmpty()) + if (Query.IsEmpty() && Type.IsEmpty()) { - Result.Append(TEXT("ERROR: At least one of Search or Type must be specified\n")); + Result.Append(TEXT("ERROR: At least one of Query or Type must be specified\n")); return; } @@ -53,9 +53,9 @@ public: Assets.NoScans().Scan(TypeClass); } - if (!Search.IsEmpty()) + if (!Query.IsEmpty()) { - Assets.Substring(Search); + Assets.Substring(Query); } Assets.AllContent().Limit(Limit).Errors(Result).Info(); diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/Handlers/UMCPHandler_SearchSpawnableNodeTypes.h b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/Handlers/UMCPHandler_SearchSpawnableNodeTypes.h index 9808b6b1..7dd75b5e 100644 --- a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/Handlers/UMCPHandler_SearchSpawnableNodeTypes.h +++ b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/Handlers/UMCPHandler_SearchSpawnableNodeTypes.h @@ -2,10 +2,10 @@ #include "CoreMinimal.h" #include "MCPHandler.h" -#include "MCPAssetFinder.h" #include "MCPFetcher.h" #include "MCPUtils.h" #include "EdGraph/EdGraph.h" +#include "EdGraph/EdGraphSchema.h" #include "UMCPHandler_SearchSpawnableNodeTypes.generated.h" @@ -25,50 +25,36 @@ public: UPROPERTY(meta=(Optional, Description="Maximum number of results (default 50, max 500)")) int32 MaxResults = 50; - UPROPERTY(meta=(Optional, Description="Blueprint path. If specified with Graph, only returns nodes compatible with that graph.")) - FString Blueprint; - - UPROPERTY(meta=(Optional, Description="Graph name to filter by compatibility. Requires Blueprint.")) + UPROPERTY(meta=(Description="MCPFetcher path to a graph, e.g. /Game/Foo,graph:EventGraph or /Game/Materials/M_Gold,graph:")) FString Graph; virtual FString GetDescription() const override { - return TEXT("Search the Blueprint action database for node spawners matching a query. " - "Returns full action names for use with spawn_node. " - "Optionally filter by blueprint+graph to only show compatible node types."); + return TEXT("Search the action database for node types that can be spawned in a graph. " + "Works with any graph type (Blueprint, Material, etc.). " + "Returns full action names for use with SpawnNodesInGraph."); } virtual void Handle(const FJsonObject* Json, FStringBuilderBase& Result) override { int32 ClampedMax = FMath::Clamp(MaxResults, 1, 500); - // Optionally resolve a graph to filter by compatibility - UEdGraph* GraphFilter = nullptr; - if (!Blueprint.IsEmpty() && !Graph.IsEmpty()) + MCPFetcher F(Result); + UEdGraph* TargetGraph = F.Walk(Graph).Cast(); + if (!TargetGraph) return; + + TArray> Actions = MCPUtils::SearchGraphActions(TargetGraph, Query, ClampedMax, /*ExactMatch=*/false); + + for (const TSharedPtr& Action : Actions) { - MCPFetcher F(Result); - F.Walk(Blueprint).Graph(Graph); - if (!F.Ok()) return; - GraphFilter = Cast(F.Obj); - if (!GraphFilter) - { - Result.Appendf(TEXT("ERROR: '%s' is not a graph\n"), *Graph); - return; - } + Result.Appendf(TEXT("%s\n"), *MCPUtils::ActionFullName(Action)); } - TArray Spawners = MCPUtils::SearchNodeSpawners(Query, ClampedMax, /*ExactMatch=*/false, GraphFilter); - - for (UBlueprintNodeSpawner* Spawner : Spawners) - { - Result.Appendf(TEXT("%s\n"), *MCPUtils::NodeSpawnerFullName(Spawner)); - } - - if (Spawners.Num() == 0) + if (Actions.Num() == 0) { Result.Append(TEXT("No matching node types found.\n")); } - else if (Spawners.Num() >= ClampedMax) + else if (Actions.Num() >= ClampedMax) { Result.Appendf(TEXT("WARNING: Reached limit of %d results. Refine your query or increase MaxResults.\n"), ClampedMax); } diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/Handlers/UMCPHandler_SearchTypeUsageInBlueprints.h b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/Handlers/UMCPHandler_SearchTypeUsageInBlueprints.h index 44cac0d3..b7e2111d 100644 --- a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/Handlers/UMCPHandler_SearchTypeUsageInBlueprints.h +++ b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/Handlers/UMCPHandler_SearchTypeUsageInBlueprints.h @@ -41,7 +41,7 @@ public: FString TypeName; UPROPERTY(meta=(Optional, Description="Filter to blueprints whose name or path contains this substring")) - FString Filter; + FString Query; UPROPERTY(meta=(Optional, Description="Maximum number of results to return (default 200, max 500)")) int32 MaxResults = 0; @@ -54,7 +54,7 @@ public: virtual void Handle(const FJsonObject* Json, FStringBuilderBase& Result) override { FString DecodedTypeName = MCPUtils::UrlDecode(TypeName); - FString FilterStr = Filter.IsEmpty() ? FString() : MCPUtils::UrlDecode(Filter); + FString FilterStr = Query.IsEmpty() ? FString() : MCPUtils::UrlDecode(Query); int32 EffectiveMaxResults = (MaxResults > 0) ? FMath::Clamp(MaxResults, 1, 500) : 200; diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/Handlers/UMCPHandler_SearchUnrealClasses.h b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/Handlers/UMCPHandler_SearchUnrealClasses.h index ff790df9..644ef454 100644 --- a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/Handlers/UMCPHandler_SearchUnrealClasses.h +++ b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/Handlers/UMCPHandler_SearchUnrealClasses.h @@ -22,7 +22,7 @@ class UMCPHandler_SearchUnrealClasses : public UObject, public IMCPHandler public: UPROPERTY(meta=(Optional, Description="Substring filter for class names")) - FString Filter; + FString Query; UPROPERTY(meta=(Optional, Description="Parent class name to restrict results to subclasses")) FString ParentClass; @@ -69,7 +69,7 @@ public: if (ParentClassObj && !Class->IsChildOf(ParentClassObj)) continue; FString ClassName = MCPUtils::FormatName(Class); - if (!Filter.IsEmpty() && !ClassName.Contains(Filter, ESearchCase::IgnoreCase)) continue; + if (!Query.IsEmpty() && !ClassName.Contains(Query, ESearchCase::IgnoreCase)) continue; TotalMatched++; if (Matches.Num() < Limit) diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/Handlers/UMCPHandler_SetProperties.h b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/Handlers/UMCPHandler_SetProperties.h new file mode 100644 index 00000000..8702a163 --- /dev/null +++ b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/Handlers/UMCPHandler_SetProperties.h @@ -0,0 +1,99 @@ +#pragma once + +#include "CoreMinimal.h" +#include "MCPHandler.h" +#include "MCPFetcher.h" +#include "MCPUtils.h" +#include "UMCPHandler_SetProperties.generated.h" + + +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + +UCLASS() +class UMCPHandler_SetProperties : public UObject, public IMCPHandler +{ + GENERATED_BODY() + +public: + UPROPERTY(meta=(Description="MCPFetcher path to the object (e.g. /Game/Materials/M_Gold or /Game/Tangibles/TAN_Char,component:Mesh0)")) + FString Path; + + UPROPERTY(meta=(Description="Object mapping property names to new values in Unreal text format")) + FMCPJsonObject Properties; + + virtual FString GetDescription() const override + { + return TEXT("Set one or more editable properties on an object resolved via MCPFetcher path. " + "Properties is a JSON object like {\"TwoSided\": \"true\", \"BlendMode\": \"BLEND_Translucent\"}."); + } + + virtual void Handle(const FJsonObject* Json, FStringBuilderBase& Result) override + { + // Resolve the path to an object. + MCPFetcher F(Result); + UObject* Obj = F.Walk(Path).Cast(); + if (!Obj) return; + + // Get the editable template (e.g. Blueprint → CDO). + UObject* Template = MCPUtils::GetEditableTemplate(Obj, Result); + if (!Template) return; + + if (!Properties.Json || Properties.Json->Values.Num() == 0) + { + Result.Append(TEXT("Error: No properties specified\n")); + return; + } + + // Validation pass — check all properties before modifying anything. + for (const auto& Pair : Properties.Json->Values) + { + FProperty* Prop = MCPUtils::FindPropertyByName(Template, Pair.Key, Result); + if (!Prop) return; + + if (!Prop->HasAnyPropertyFlags(CPF_Edit)) + { + Result.Appendf(TEXT("Error: Property '%s' is not editable (no Edit flag)\n"), *Pair.Key); + return; + } + + FString ValueStr; + if (!Pair.Value->TryGetString(ValueStr)) + { + Result.Appendf(TEXT("Error: Value for '%s' must be a string\n"), *Pair.Key); + return; + } + } + + // Apply all changes in a single Pre/PostEditChange bracket. + Template->PreEditChange(nullptr); + + int32 SuccessCount = 0; + for (const auto& Pair : Properties.Json->Values) + { + FProperty* Prop = MCPUtils::FindPropertyByName(Template, Pair.Key); + FString ValueStr; + Pair.Value->TryGetString(ValueStr); + + FString OldValue = MCPUtils::GetPropertyValueText(Template, Prop); + + if (!MCPUtils::SetPropertyValueText(Template, Prop, ValueStr, Result)) + continue; + + FString NewValue = MCPUtils::GetPropertyValueText(Template, Prop); + Result.Appendf(TEXT("%s: %s -> %s\n"), *MCPUtils::FormatName(Prop), *OldValue, *NewValue); + SuccessCount++; + } + + Template->PostEditChange(); + + // Save. + Template->MarkPackageDirty(); + bool bSaved = MCPUtils::SaveGenericPackage(Obj); + + Result.Appendf(TEXT("Set %d/%d properties.\n"), SuccessCount, Properties.Json->Values.Num()); + if (!bSaved) + Result.Append(TEXT("Warning: Save failed\n")); + } +}; diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/Handlers/UMCPHandler_ShowCommands.h b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/Handlers/UMCPHandler_ShowCommands.h index 9ec5ea22..8e0ee1c4 100644 --- a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/Handlers/UMCPHandler_ShowCommands.h +++ b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/Handlers/UMCPHandler_ShowCommands.h @@ -11,6 +11,9 @@ class UMCPHandler_ShowCommands : public UObject, public IMCPHandler GENERATED_BODY() public: + UPROPERTY(meta=(Optional, Description="Substring filter for command names")) + FString Query; + UPROPERTY(meta=(Optional, Description="If true, return full details including parameter types and descriptions")) bool Verbose = false; @@ -21,8 +24,14 @@ public: virtual void Handle(const FJsonObject* Json, FStringBuilderBase& Result) override { + FString QueryLower = Query.ToLower(); + for (UClass* Class : MCPUtils::CollectHandlerClasses()) { + FString ToolName = MCPUtils::GetToolName(Class); + if (!ToolName.ToLower().Contains(QueryLower)) + continue; + if (Verbose) { MCPUtils::FormatCommandHelp(Class, Result); diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/Handlers/UMCPHandler_SpawnNodesInGraph.h b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/Handlers/UMCPHandler_SpawnNodes.h similarity index 66% rename from Plugins/BlueprintMCP/Source/BlueprintMCP/Private/Handlers/UMCPHandler_SpawnNodesInGraph.h rename to Plugins/BlueprintMCP/Source/BlueprintMCP/Private/Handlers/UMCPHandler_SpawnNodes.h index f0b12902..81035ba2 100644 --- a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/Handlers/UMCPHandler_SpawnNodesInGraph.h +++ b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/Handlers/UMCPHandler_SpawnNodes.h @@ -7,9 +7,9 @@ #include "Engine/Blueprint.h" #include "EdGraph/EdGraph.h" #include "EdGraph/EdGraphNode.h" -#include "BlueprintNodeSpawner.h" +#include "EdGraph/EdGraphSchema.h" #include "Kismet2/BlueprintEditorUtils.h" -#include "UMCPHandler_SpawnNodesInGraph.generated.h" +#include "UMCPHandler_SpawnNodes.generated.h" // --------------------------------------------------------------------------- @@ -33,7 +33,7 @@ struct FSpawnNodeEntry UCLASS() -class UMCPHandler_SpawnNodesInGraph : public UObject, public IMCPHandler +class UMCPHandler_SpawnNodes : public UObject, public IMCPHandler { GENERATED_BODY() @@ -41,14 +41,14 @@ public: UPROPERTY(meta=(Description="Path to a graph, e.g. /Game/Foo,graph:EventGraph")) FString Graph; - UPROPERTY(meta=(Description="Array of {actionName, posX, posY} objects. Use search_spawnable_node_types to find action names.")) + UPROPERTY(meta=(Description="Array of {actionName, posX, posY} objects. Use SearchSpawnableNodeTypes to find action names.")) FMCPJsonArray Nodes; virtual FString GetDescription() const override { - return TEXT("Create nodes in a Blueprint graph using the editor's action database. " - "Can create ANY node type that appears in the editor's right-click menu, including custom K2 nodes. " - "Use search_spawnable_node_types first to find the exact action name."); + return TEXT("Create nodes in any graph (Blueprint, Material, etc.) using the editor's action database. " + "Can create ANY node type that appears in the editor's right-click menu. " + "Use SearchSpawnableNodeTypes first to find the exact action name."); } virtual void Handle(const FJsonObject* Json, FStringBuilderBase& Result) override @@ -66,28 +66,27 @@ public: if (!MCPUtils::PopulateFromJson(FSpawnNodeEntry::StaticStruct(), &Entry, NodeVal, Result)) continue; - // Find the spawner by exact full name - TArray Matches = MCPUtils::SearchNodeSpawners(Entry.ActionName, 0, /*ExactMatch=*/true, TargetGraph); + // Find the action by exact full name + TArray> Matches = MCPUtils::SearchGraphActions(TargetGraph, Entry.ActionName, 0, /*ExactMatch=*/true); if (Matches.Num() == 0) { - Result.Appendf(TEXT("ERROR: No action found matching '%s'. Use search_spawnable_node_types to find available actions.\n"), + Result.Appendf(TEXT("ERROR: No action found matching '%s'. Use SearchSpawnableNodeTypes to find available actions.\n"), *Entry.ActionName); continue; } if (Matches.Num() > 1) { - Result.Appendf(TEXT("ERROR: Ambiguous: %d spawners match '%s'.\n"), + Result.Appendf(TEXT("ERROR: Ambiguous: %d actions match '%s'.\n"), Matches.Num(), *Entry.ActionName); continue; } - // Invoke the spawner + // Perform the action FVector2D Location(Entry.PosX, Entry.PosY); - IBlueprintNodeBinder::FBindingSet Bindings; - UEdGraphNode* NewNode = Matches[0]->Invoke(TargetGraph, Bindings, Location); + UEdGraphNode* NewNode = Matches[0]->PerformAction(TargetGraph, nullptr, Location, /*bSelectNewNode=*/false); if (!NewNode) { - Result.Appendf(TEXT("ERROR: Spawner Invoke() returned null for '%s'.\n"), *Entry.ActionName); + Result.Appendf(TEXT("ERROR: PerformAction returned null for '%s'.\n"), *Entry.ActionName); continue; } @@ -99,10 +98,17 @@ public: SuccessCount++; } + // Mark the owning asset as modified UBlueprint* BP = Cast(TargetGraph->GetOuter()); if (BP) FBlueprintEditorUtils::MarkBlueprintAsStructurallyModified(BP); + TargetGraph->NotifyGraphChanged(); + + UObject* Outer = TargetGraph->GetOuter(); + if (Outer) + Outer->MarkPackageDirty(); + Result.Appendf(TEXT("Spawned %d/%d nodes.\n"), SuccessCount, TotalCount); } }; diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPAssetFinder.cpp b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPAssetFinder.cpp index 26a970e8..c1713219 100644 --- a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPAssetFinder.cpp +++ b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPAssetFinder.cpp @@ -3,6 +3,7 @@ #include "Engine/World.h" #include "Engine/Level.h" #include "Engine/LevelScriptBlueprint.h" +#include "Materials/Material.h" #include "MCPUtils.h" #include "AssetRegistry/AssetRegistryModule.h" #include "AssetRegistry/IAssetRegistry.h" @@ -101,11 +102,14 @@ bool MCPAssetsBase::Load() for (const FAssetData &Asset : AssetsToLoad) { UObject *Obj = TryLoadAsset(Asset); - if (Obj != nullptr) - { - AssetResults.Add(Asset); - UObjectResults.Add(Obj); - } + if (!Obj) continue; + + // If this is a material open in the editor, use the editor's transient copy. + if (UMaterial* Mat = Cast(Obj)) + Obj = MCPUtils::ReplaceMaterialWithTransientCopy(Mat); + + AssetResults.Add(Asset); + UObjectResults.Add(Obj); } if (bErrorIfNone && AssetResults.IsEmpty()) { diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPFetcher.cpp b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPFetcher.cpp index 16c5455e..e8cd587c 100644 --- a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPFetcher.cpp +++ b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPFetcher.cpp @@ -8,6 +8,8 @@ #include "Engine/SimpleConstructionScript.h" #include "Engine/SCS_Node.h" #include "Engine/World.h" +#include "Materials/Material.h" +#include "MaterialGraph/MaterialGraph.h" #include "Engine/LevelScriptBlueprint.h" MCPFetcher& MCPFetcher::SetError(const FString& Msg) @@ -55,7 +57,8 @@ MCPFetcher& MCPFetcher::Walk(const FString& Path) for (int32 i = Start; i < Segments.Num(); i++) { FString Key, Value; - Segments[i].Split(TEXT(":"), &Key, &Value); + if (!Segments[i].Split(TEXT(":"), &Key, &Value)) + Key = Segments[i]; if (StrEq(Key, TEXT("graph"))) Graph(Value); else if (StrEq(Key, TEXT("node"))) Node(Value); @@ -80,15 +83,31 @@ void MCPFetcher::LoadUAsset(const FString& PackagePath) SetObj(LoadObject(nullptr, *PackagePath)); if (!Obj) SetError(FString::Printf(TEXT("Could not load asset '%s'"), *PackagePath)); + + // If this is a material open in the editor, use the editor's transient copy. + if (UMaterial* Mat = ::Cast(Obj)) + SetObj(MCPUtils::ReplaceMaterialWithTransientCopy(Mat)); } MCPFetcher& MCPFetcher::Graph(const FString& Value) { if (bError) return *this; + // Material with blank graph name → navigate to the material graph. + if (UMaterial* Mat = ::Cast(Obj)) + { + if (!Value.IsEmpty()) + return SetError(FString::Printf(TEXT("Materials do not have named graphs (got '%s')"), *Value)); + MCPUtils::EnsureMaterialGraph(Mat); + if (!Mat->MaterialGraph) + return SetError(FString::Printf(TEXT("Material '%s' has no material graph"), *Mat->GetName())); + SetObj(Mat->MaterialGraph); + return *this; + } + UBlueprint* BP = ::Cast(Obj); if (!BP) - return TypeMismatch(TEXT("graph"), TEXT("Blueprint")); + return TypeMismatch(TEXT("graph"), TEXT("Blueprint or Material")); TArray Matches = MCPUtils::AllGraphsNamed(BP, Value); if (Matches.Num() == 0) diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers.cpp b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers.cpp index 5274731f..07ae9409 100644 --- a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers.cpp +++ b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers.cpp @@ -34,9 +34,9 @@ #include "Handlers/UMCPHandler_DisconnectPins.h" #include "Handlers/UMCPHandler_DisconnectMaterialExpressionPin.h" #include "Handlers/UMCPHandler_DumpBlueprint.h" +#include "Handlers/UMCPHandler_DumpProperties.h" #include "Handlers/UMCPHandler_DumpGraphs.h" #include "Handlers/UMCPHandler_DumpMaterial.h" -#include "Handlers/UMCPHandler_DumpMaterialExpressionGraph.h" #include "Handlers/UMCPHandler_DumpMaterialFunction.h" #include "Handlers/UMCPHandler_DumpMaterialInstanceParameters.h" #include "Handlers/UMCPHandler_DuplicateNodesInGraph.h" @@ -47,10 +47,12 @@ #include "Handlers/UMCPHandler_ListAnimSlotNames.h" #include "Handlers/UMCPHandler_ListAnimSyncGroups.h" #include "Handlers/UMCPHandler_ListBlueprintAssets.h" +#include "Handlers/UMCPHandler_ListOpenAssetEditors.h" #include "Handlers/UMCPHandler_ListBlueprintComponents.h" #include "Handlers/UMCPHandler_ListBlueprintInterfaces.h" #include "Handlers/UMCPHandler_ListClassProperties.h" #include "Handlers/UMCPHandler_ListEventDispatchers.h" +#include "Handlers/UMCPHandler_OpenAssetEditor.h" #include "Handlers/UMCPHandler_RefreshAllNodesInGraph.h" #include "Handlers/UMCPHandler_RemoveAnimStateFromMachine.h" #include "Handlers/UMCPHandler_RemoveBlueprintComponent.h" @@ -81,7 +83,8 @@ #include "Handlers/UMCPHandler_SetMaterialInstanceParameter.h" #include "Handlers/UMCPHandler_SetMaterialProperty.h" #include "Handlers/UMCPHandler_SetNodeComment.h" +#include "Handlers/UMCPHandler_SetProperties.h" #include "Handlers/UMCPHandler_SetNodePositions.h" #include "Handlers/UMCPHandler_SetPinDefaultValues.h" #include "Handlers/UMCPHandler_ShowCommands.h" -#include "Handlers/UMCPHandler_SpawnNodesInGraph.h" +#include "Handlers/UMCPHandler_SpawnNodes.h" diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPUtils.cpp b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPUtils.cpp index 9b62f9a0..c7866547 100644 --- a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPUtils.cpp +++ b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPUtils.cpp @@ -1,7 +1,5 @@ #include "MCPUtils.h" #include "MCPHandler.h" -#include "BlueprintActionDatabase.h" -#include "BlueprintNodeSpawner.h" #include "Dom/JsonValue.h" #include "Serialization/JsonReader.h" #include "Serialization/JsonWriter.h" @@ -73,6 +71,8 @@ #include "MaterialGraph/MaterialGraph.h" #include "MaterialGraph/MaterialGraphNode.h" #include "MaterialGraph/MaterialGraphSchema.h" +#include "IMaterialEditor.h" +#include "Subsystems/AssetEditorSubsystem.h" // Mesh, animation, texture support #include "Engine/StaticMesh.h" @@ -102,7 +102,7 @@ MCPErrorCallback::MCPErrorCallback(FString& OutError) {} MCPErrorCallback::MCPErrorCallback(FStringBuilderBase& OutResult) - : Func([&OutResult](const FString& Msg) { OutResult.Reset(); OutResult.Appendf(TEXT("ERROR: %s\n"), *Msg); }) + : Func([&OutResult](const FString& Msg) { OutResult.Appendf(TEXT("ERROR: %s\n"), *Msg); }) {} // ============================================================ @@ -186,9 +186,9 @@ FString MCPUtils::FormatName(const FBPVariableDescription &Var) return Name; } -FString MCPUtils::FormatName(const UClass *Class) +FString MCPUtils::FormatName(const UStruct *Struct) { - FString Name = Class->GetName(); + FString Name = Struct->GetName(); SanitizeNameInPlace(Name); return Name; } @@ -254,9 +254,19 @@ FString MCPUtils::FormatName(const UEnum *Enum) return Name; } -bool MCPUtils::Identifies(const FString &Name, const UClass *Class) +FString MCPUtils::FormatName(const FProperty *Prop) { - return FormatName(Class).Equals(Name, ESearchCase::IgnoreCase); + return Prop->GetName(); +} + +bool MCPUtils::Identifies(const FString &Name, const FBPVariableDescription &Var) +{ + return FormatName(Var).Equals(Name, ESearchCase::IgnoreCase); +} + +bool MCPUtils::Identifies(const FString &Name, const UStruct *Struct) +{ + return FormatName(Struct).Equals(Name, ESearchCase::IgnoreCase); } bool MCPUtils::Identifies(const FString &Name, const UMaterial *Material) @@ -314,6 +324,11 @@ bool MCPUtils::Identifies(const FString &Name, const UEnum *Enum) return FormatName(Enum).Equals(Name, ESearchCase::IgnoreCase); } +bool MCPUtils::Identifies(const FString &Name, const FProperty *Prop) +{ + return FormatName(Prop).Equals(Name, ESearchCase::IgnoreCase); +} + // ============================================================ // Identifies // ============================================================ @@ -1167,6 +1182,34 @@ void MCPUtils::EnsureMaterialGraph(UMaterial* Material) } } +UMaterial* MCPUtils::ReplaceMaterialWithTransientCopy(UMaterial* Material) +{ + if (!Material) return nullptr; + + // Already a preview material — nothing to do. + if (Material->GetOutermost() == GetTransientPackage()) + return Material; + + // If the material editor has a transient preview copy open, get it + // via the editor API. This follows the same pattern as Epic's + // MaterialEditingLibrary (FindMaterialEditorForAsset). + UAssetEditorSubsystem* Sub = GEditor->GetEditorSubsystem(); + IAssetEditorInstance* EditorInstance = Sub ? Sub->FindEditorForAsset(Material, false) : nullptr; + if (EditorInstance) + { + // This is a weird hack. We know that the IAssetEditorInstance for a material + // is always going to be an FMaterialEditor, which conforms to IMaterialEditor. + // If that weren't the case, this unsafe code would crash hard. However, + // lots of places in unreal use this same unsafe pattern. + IMaterialEditor* MatEditor = static_cast(EditorInstance); + UMaterialInterface* Edited = MatEditor->GetMaterialInterface(); + if (UMaterial* EditedMat = Cast(Edited)) + return EditedMat; + } + + return Material; +} + bool MCPUtils::SaveMaterialPackage(UMaterial* Material) { if (!Material) return false; @@ -1392,70 +1435,53 @@ UAnimStateTransitionNode* MCPUtils::FindTransition(UAnimationStateMachineGraph* } // ============================================================ -// Node spawners +// Graph actions (node spawning) // ============================================================ -FString MCPUtils::NodeSpawnerFullName(UBlueprintNodeSpawner* Spawner) +#include "EdGraph/EdGraphSchema.h" + +FString MCPUtils::ActionFullName(const TSharedPtr& Action) { - const FBlueprintActionUiSpec& UiSpec = Spawner->PrimeDefaultUiSpec(); - FString Category = UiSpec.Category.ToString(); - FString MenuName = UiSpec.MenuName.ToString(); + FString Category = Action->GetCategory().ToString(); + FString MenuName = Action->GetMenuDescription().ToString(); if (Category.IsEmpty()) - { return MenuName; - } return Category + TEXT("|") + MenuName; } -TArray MCPUtils::SearchNodeSpawners(const FString& Query, int32 MaxResults, bool ExactMatch, UEdGraph* GraphFilter) +TArray> MCPUtils::SearchGraphActions(UEdGraph* Graph, const FString& Query, int32 MaxResults, bool ExactMatch) { FString QueryLower = Query.ToLower(); - TArray Result; + TArray> Result; - for (const auto& Pair : FBlueprintActionDatabase::Get().GetAllActions()) + FGraphContextMenuBuilder ContextMenuBuilder(Graph); + Graph->GetSchema()->GetGraphContextActions(ContextMenuBuilder); + + for (int32 i = 0; i < ContextMenuBuilder.GetNumActions(); i++) { - for (UBlueprintNodeSpawner* Spawner : Pair.Value) + TSharedPtr Action = ContextMenuBuilder.GetSchemaAction(i); + if (!Action.IsValid()) continue; + + FString FullName = ActionFullName(Action); + if (FullName.IsEmpty()) continue; + + if (ExactMatch) { - if (!Spawner) continue; - if (Spawner->PrimeDefaultUiSpec().MenuName.IsEmpty()) continue; - - // Filter by graph compatibility if a graph was provided - if (GraphFilter && Spawner->NodeClass) - { - UEdGraphNode* NodeCDO = CastChecked(Spawner->NodeClass->ClassDefaultObject); - if (!NodeCDO->IsCompatibleWithGraph(GraphFilter)) - { - continue; - } - } - - FString FullName = NodeSpawnerFullName(Spawner); - - if (ExactMatch) - { - if (FullName.ToLower() != QueryLower) - { - continue; - } - } - else - { - FString Keywords = Spawner->PrimeDefaultUiSpec().Keywords.ToString(); - if (!FullName.ToLower().Contains(QueryLower) - && !Keywords.ToLower().Contains(QueryLower)) - { - continue; - } - } - - Result.Add(Spawner); - - if ((MaxResults > 0) && (Result.Num() >= MaxResults)) - { - break; - } + if (FullName.ToLower() != QueryLower) + continue; } + else + { + FString Keywords = Action->GetKeywords().ToString(); + if (!FullName.ToLower().Contains(QueryLower) && !Keywords.ToLower().Contains(QueryLower)) + continue; + } + + Result.Add(Action); + if ((MaxResults > 0) && (Result.Num() >= MaxResults)) + break; } + return Result; } @@ -1734,6 +1760,121 @@ FString MCPUtils::FormatPropertyType(FProperty* Prop) return TEXT("string"); } +// ============================================================ +// GetEditableTemplate +// ============================================================ + +UObject* MCPUtils::GetEditableTemplate(UObject* Obj, MCPErrorCallback Error) +{ + if (!Obj) + { + Error.SetError(TEXT("Object is null")); + return nullptr; + } + + // Blueprint → operate on the Class Default Object. + if (UBlueprint* BP = Cast(Obj)) + { + if (!BP->GeneratedClass) + { + Error.SetError(FString::Printf(TEXT("Blueprint '%s' has no GeneratedClass"), *Obj->GetName())); + return nullptr; + } + return BP->GeneratedClass->GetDefaultObject(); + } + + // These asset types are safe to edit directly. + if (Cast(Obj)) return Obj; + if (Cast(Obj)) return Obj; + if (Cast(Obj)) return Obj; + if (Cast(Obj)) return Obj; + if (Cast(Obj)) return Obj; + if (Cast(Obj)) return Obj; + + // Unknown type — refuse for safety. + Error.SetError(FString::Printf(TEXT("Object type '%s' is not supported for generic property editing"), *Obj->GetClass()->GetName())); + return nullptr; +} + +// ============================================================ +// FindPropertyByName +// ============================================================ + +FProperty* MCPUtils::FindPropertyByName(UObject* Obj, const FString& Name, MCPErrorCallback Error) +{ + if (!Obj) + { + Error.SetError(TEXT("Object is null")); + return nullptr; + } + + FProperty* Found = nullptr; + for (TFieldIterator PropIt(Obj->GetClass()); PropIt; ++PropIt) + { + if (!Identifies(Name, *PropIt)) continue; + if (Found) + { + Error.SetError(FString::Printf(TEXT("Ambiguous property '%s' on %s"), *Name, *FormatName(Obj->GetClass()))); + return nullptr; + } + Found = *PropIt; + } + + if (!Found) + Error.SetError(FString::Printf(TEXT("Property '%s' not found on %s"), *Name, *FormatName(Obj->GetClass()))); + + return Found; +} + +// ============================================================ +// GetPropertyValueText +// ============================================================ + +FString MCPUtils::GetPropertyValueText(UObject* Container, FProperty* Prop) +{ + FString Result; + void* ValuePtr = Prop->ContainerPtrToValuePtr(Container); + Prop->ExportTextItem_Direct(Result, ValuePtr, nullptr, Container, PPF_None); + return Result; +} + +// ============================================================ +// SetPropertyValueText +// ============================================================ + +bool MCPUtils::SetPropertyValueText(UObject* Container, FProperty* Prop, const FString& Value, MCPErrorCallback Error) +{ + void* ValuePtr = Prop->ContainerPtrToValuePtr(Container); + const TCHAR* ImportResult = Prop->ImportText_Direct(*Value, ValuePtr, Container, PPF_None); + if (!ImportResult) + { + Error.SetError(FString::Printf(TEXT("Failed to parse '%s' for property '%s' (type: %s)"), + *Value, *FormatName(Prop), *Prop->GetCPPType())); + return false; + } + return true; +} + +// ============================================================ +// BlueprintVisibleProperties +// ============================================================ + +TArray MCPUtils::BlueprintVisibleProperties(UObject* Obj, const FString& Query) +{ + TArray Result; + if (!Obj) return Result; + for (TFieldIterator PropIt(Obj->GetClass()); PropIt; ++PropIt) + { + FProperty* Prop = *PropIt; + if (!Prop) continue; + if (!Prop->HasAnyPropertyFlags(CPF_BlueprintVisible)) continue; + if (!Query.IsEmpty() && !FormatName(Prop).Contains(Query, ESearchCase::IgnoreCase)) + continue; + Result.Add(Prop); + } + return Result; +} + // ============================================================ // FormatCommandHelp — verbose description of one handler command // ============================================================ diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Public/MCPUtils.h b/Plugins/BlueprintMCP/Source/BlueprintMCP/Public/MCPUtils.h index 62aed7ed..cc4321dc 100644 --- a/Plugins/BlueprintMCP/Source/BlueprintMCP/Public/MCPUtils.h +++ b/Plugins/BlueprintMCP/Source/BlueprintMCP/Public/MCPUtils.h @@ -12,7 +12,7 @@ class UMaterial; class UMaterialInstance; class UMaterialFunction; class UMaterialExpression; -class UBlueprintNodeSpawner; +struct FEdGraphSchemaAction; class UAnimationStateMachineGraph; class UAnimStateNode; class UAnimStateTransitionNode; @@ -122,7 +122,7 @@ public: static FString FormatName(const UEdGraphPin *Pin); static FString FormatName(const FMemberReference &Ref); static FString FormatName(const FBPVariableDescription &Var); - static FString FormatName(const UClass *Class); + static FString FormatName(const UStruct *Struct); static FString FormatName(const UMaterial *Material); static FString FormatName(const UMaterialInstance *MaterialInstance); static FString FormatName(const UMaterialFunction *MaterialFunction); @@ -134,6 +134,7 @@ public: static FString FormatName(const UTexture *Texture); static FString FormatName(const UScriptStruct *Struct); static FString FormatName(const UEnum *Enum); + static FString FormatName(const FProperty *Prop); //////////////////////////////////////////////////////// // @@ -154,7 +155,8 @@ public: static bool Identifies(const FString &Name, const UEdGraphNode* Node); static bool Identifies(const FString &Name, const UEdGraphPin *Pin); static bool Identifies(const FString &Name, const FMemberReference &Ref); - static bool Identifies(const FString &Name, const UClass *Class); + static bool Identifies(const FString &Name, const FBPVariableDescription &Var); + static bool Identifies(const FString &Name, const UStruct *Struct); static bool Identifies(const FString &Name, const UMaterial *Material); static bool Identifies(const FString &Name, const UMaterialInstance *MaterialInstance); static bool Identifies(const FString &Name, const UMaterialFunction *MaterialFunction); @@ -166,6 +168,7 @@ public: static bool Identifies(const FString &Name, const UTexture *Texture); static bool Identifies(const FString &Name, const UScriptStruct *Struct); static bool Identifies(const FString &Name, const UEnum *Enum); + static bool Identifies(const FString &Name, const FProperty *Prop); //////////////////////////////////////////////////////// @@ -265,14 +268,30 @@ public: static bool SaveMaterialPackage(UMaterial* Material); static bool SaveGenericPackage(UObject* Asset); + // If the material editor has a transient preview copy of this material, + // return that copy (which is what the editor is actually working on). + // Otherwise return the original. + static UMaterial* ReplaceMaterialWithTransientCopy(UMaterial* Material); + // ----- Anim blueprint helpers ----- static UAnimationStateMachineGraph* FindStateMachineGraph(UBlueprint* BP, const FString& GraphName); static UAnimStateNode* FindStateByName(UAnimationStateMachineGraph* SMGraph, const FString& StateName, MCPErrorCallback Error); static UAnimStateTransitionNode* FindTransition(UAnimationStateMachineGraph* SMGraph, const FString& FromStateName, const FString& ToStateName); - // ----- Node spawners ----- - static FString NodeSpawnerFullName(UBlueprintNodeSpawner* Spawner); - static TArray SearchNodeSpawners(const FString& Query, int32 MaxResults = 0, bool ExactMatch = false, UEdGraph* GraphFilter = nullptr); + // ----- Graph actions (node spawning) ----- + static FString ActionFullName(const TSharedPtr& Action); + static TArray> SearchGraphActions(UEdGraph* Graph, const FString& Query, int32 MaxResults = 0, bool ExactMatch = false); + + // ----- Editable template ----- + // Given an object, returns the appropriate template object for generic + // property editing, or nullptr if the type isn't whitelisted. + // UBlueprint → CDO; UMaterial, UActorComponent, etc. → as-is. + static UObject* GetEditableTemplate(UObject* Obj, MCPErrorCallback Error); + static TArray BlueprintVisibleProperties(UObject* Obj, const FString& Query = FString()); + + static FProperty* FindPropertyByName(UObject* Obj, const FString& Name, MCPErrorCallback Error = nullptr); + static FString GetPropertyValueText(UObject* Container, FProperty* Prop); + static bool SetPropertyValueText(UObject* Container, FProperty* Prop, const FString& Value, MCPErrorCallback Error = nullptr); // ----- Property population ----- static FString PropertyNameToJsonKey(const FString& PropName); diff --git a/tools/split-handlers.py b/tools/split-handlers.py deleted file mode 100644 index 75570299..00000000 --- a/tools/split-handlers.py +++ /dev/null @@ -1,92 +0,0 @@ -#!/usr/bin/env python3 -"""Split a handler source file into one file per UMCPHandler class. - -Usage: python3 tools/split-handlers.py - -Reads the #include block from the top of the file, then splits at the -closing }; (column 0) of each UMCPHandler class. Each output file is -named after the handler class and placed in the same directory. The -shared #include block is prepended to every output file. -""" - -import sys -import re -import os - - -def main(): - if len(sys.argv) != 2: - print(f"Usage: {sys.argv[0]} ") - sys.exit(1) - - filepath = sys.argv[1] - with open(filepath) as f: - lines = f.readlines() - - # Extract the #include block from the top of the file. - # Collect #pragma, #include, blank lines, and // comment lines - # until we hit something else. - include_block = [] - body_start = 0 - for i, line in enumerate(lines): - stripped = line.strip() - if (stripped == '' or stripped.startswith('#pragma') or - stripped.startswith('#include') or stripped.startswith('//')): - include_block.append(line) - else: - body_start = i - break - - # Strip trailing blank/comment lines from include block - while include_block and include_block[-1].strip() in ('', ) or ( - include_block and include_block[-1].strip().startswith('//')): - body_start -= 1 - include_block.pop() - if not include_block: - break - - # Remove the .generated.h include from the shared block (each output - # file gets its own). - include_block = [l for l in include_block if '.generated.h' not in l] - - body_lines = lines[body_start:] - - # Find split points in the body: after each }; that closes a UMCPHandler. - handler_name = None - splits = [] # list of (end_index_exclusive_in_body, class_name) - - for i, line in enumerate(body_lines): - m = re.match(r'class (UMCPHandler_\w+)', line) - if m: - handler_name = m.group(1) - if line.startswith('};') and handler_name: - splits.append((i + 1, handler_name)) - handler_name = None - - if not splits: - print("No UMCPHandler classes found.") - sys.exit(1) - - dirname = os.path.dirname(filepath) - start = 0 - for end, name in splits: - outpath = os.path.join(dirname, name + '.h') - with open(outpath, 'w') as f: - f.writelines(include_block) - f.write(f'#include "{name}.generated.h"\n') - f.write('\n') - f.writelines(body_lines[start:end]) - print(f" {name}.h ({end - start} lines)") - start = end - - # Warn about trailing content - if start < len(body_lines): - remaining = ''.join(body_lines[start:]).strip() - if remaining: - print(f"Warning: {len(body_lines) - start} trailing lines not included in any output file:") - for line in body_lines[start:]: - print(f" | {line}", end='') - - -if __name__ == '__main__': - main()