Implemented new framework for MCP Handlers. Made one handler with the new framework.

This commit is contained in:
2026-03-06 00:08:30 -05:00
parent 9b32de024a
commit cf1c2bf8cf
71 changed files with 440 additions and 74415 deletions

View File

@@ -1,3 +1,4 @@
#include "BlueprintMCPHandlers_Mutation.h"
#include "BlueprintMCPServer.h"
#include "Engine/Blueprint.h"
#include "Materials/Material.h"
@@ -2594,42 +2595,29 @@ void FBlueprintMCPServer::HandleSearchNodeActions(const FJsonObject* Json, FJson
// Takes a full action name, finds the spawner, and calls Invoke().
// ============================================================
void FBlueprintMCPServer::HandleSpawnNode(const FJsonObject* Json, FJsonObject* Result)
void UMCPHandler_SpawnNode::Handle(const FJsonObject* Json, FJsonObject* Result)
{
FString BlueprintName = Json->GetStringField(TEXT("blueprint"));
FString GraphName = Json->GetStringField(TEXT("graph"));
FString ActionName = Json->GetStringField(TEXT("actionName"));
if (BlueprintName.IsEmpty() || GraphName.IsEmpty() || ActionName.IsEmpty())
{
return MakeErrorJson(Result, TEXT("Missing required fields: blueprint, graph, actionName"));
}
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"));
MCPHelper* Helper = MCPHelper::Get();
// Load Blueprint
FString LoadError;
UBlueprint* BP = LoadBlueprintByName(BlueprintName, LoadError);
UBlueprint* BP = Helper->LoadBlueprintByName(Blueprint, LoadError);
if (!BP)
{
return MakeErrorJson(Result, LoadError);
return Helper->MakeErrorJson(Result, LoadError);
}
// Find the target graph
FString DecodedGraphName = UrlDecode(GraphName);
FString DecodedGraphName = MCPHelper::UrlDecode(Graph);
UEdGraph* TargetGraph = nullptr;
TArray<UEdGraph*> AllGraphs;
BP->GetAllGraphs(AllGraphs);
for (UEdGraph* Graph : AllGraphs)
for (UEdGraph* G : AllGraphs)
{
if (Graph && Graph->GetName().Equals(DecodedGraphName, ESearchCase::IgnoreCase))
if (G && G->GetName().Equals(DecodedGraphName, ESearchCase::IgnoreCase))
{
TargetGraph = Graph;
TargetGraph = G;
break;
}
}
@@ -2637,11 +2625,11 @@ void FBlueprintMCPServer::HandleSpawnNode(const FJsonObject* Json, FJsonObject*
if (!TargetGraph)
{
TArray<TSharedPtr<FJsonValue>> GraphNames;
for (UEdGraph* Graph : AllGraphs)
for (UEdGraph* G : AllGraphs)
{
if (Graph) GraphNames.Add(MakeShared<FJsonValueString>(Graph->GetName()));
if (G) GraphNames.Add(MakeShared<FJsonValueString>(G->GetName()));
}
MakeErrorJson(Result, FString::Printf(TEXT("Graph '%s' not found"), *DecodedGraphName));
Helper->MakeErrorJson(Result, FString::Printf(TEXT("Graph '%s' not found"), *DecodedGraphName));
Result->SetArrayField(TEXT("availableGraphs"), GraphNames);
return;
}
@@ -2650,13 +2638,13 @@ void FBlueprintMCPServer::HandleSpawnNode(const FJsonObject* Json, FJsonObject*
TArray<UBlueprintNodeSpawner*> Matches = FNodeActionSearch::FindSpawner(ActionName);
if (Matches.Num() == 0)
{
return MakeErrorJson(Result, FString::Printf(
return Helper->MakeErrorJson(Result, FString::Printf(
TEXT("No action found matching '%s'. Use search_node_actions to find available actions."),
*ActionName));
}
if (Matches.Num() > 1)
{
return MakeErrorJson(Result, FString::Printf(
return Helper->MakeErrorJson(Result, FString::Printf(
TEXT("Ambiguous: %d spawners match '%s'. Cannot determine which one to use."),
Matches.Num(), *ActionName));
}
@@ -2669,7 +2657,7 @@ void FBlueprintMCPServer::HandleSpawnNode(const FJsonObject* Json, FJsonObject*
if (!NewNode)
{
return MakeErrorJson(Result, TEXT("Spawner Invoke() returned null node creation failed."));
return Helper->MakeErrorJson(Result, TEXT("Spawner Invoke() returned nullnode creation failed."));
}
// Ensure valid GUID
@@ -2678,22 +2666,20 @@ void FBlueprintMCPServer::HandleSpawnNode(const FJsonObject* Json, FJsonObject*
NewNode->CreateNewGuid();
}
// Mark as modified and save
FBlueprintEditorUtils::MarkBlueprintAsStructurallyModified(BP);
bool bSaved = SaveBlueprintPackage(BP);
UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Spawned node '%s' (class %s) via action '%s' in graph '%s' of '%s'"),
*NewNode->NodeGuid.ToString(),
*NewNode->GetClass()->GetName(),
*ActionName,
*DecodedGraphName,
*BlueprintName);
*Blueprint);
// Serialize result
TSharedPtr<FJsonObject> NodeState = SerializeNode(NewNode);
TSharedPtr<FJsonObject> NodeState = Helper->SerializeNode(NewNode);
Result->SetBoolField(TEXT("success"), true);
Result->SetStringField(TEXT("blueprint"), BlueprintName);
Result->SetStringField(TEXT("blueprint"), Blueprint);
Result->SetStringField(TEXT("graph"), DecodedGraphName);
Result->SetStringField(TEXT("actionName"), ActionName);
Result->SetStringField(TEXT("nodeId"), NewNode->NodeGuid.ToString());
@@ -2703,5 +2689,5 @@ void FBlueprintMCPServer::HandleSpawnNode(const FJsonObject* Json, FJsonObject*
{
Result->SetObjectField(TEXT("node"), NodeState);
}
Result->SetBoolField(TEXT("saved"), bSaved);
}

View File

@@ -1,4 +1,5 @@
#include "BlueprintMCPServer.h"
#include "MCPHandler.h"
#include "Materials/MaterialExpression.h"
#include "AssetRegistry/AssetRegistryModule.h"
#include "AssetRegistry/IAssetRegistry.h"
@@ -96,6 +97,19 @@
#include "AnimationGraph.h"
#include "AnimationTransitionGraph.h"
// ============================================================
// Get() — retrieve the active server via the editor subsystem
// ============================================================
#include "BlueprintMCPEditorSubsystem.h"
FBlueprintMCPServer* FBlueprintMCPServer::Get()
{
if (!GEditor) return nullptr;
auto* Sub = GEditor->GetEditorSubsystem<UBlueprintMCPEditorSubsystem>();
return Sub ? Sub->GetServer() : nullptr;
}
// ============================================================
// Helpers
// ============================================================
@@ -638,7 +652,7 @@ bool FBlueprintMCPServer::Start(int32 InPort, bool bEditorMode)
Router->BindRoute(FHttpPath(TEXT("/api/search-node-actions")), EHttpServerRequestVerbs::VERB_POST,
QueuedHandler(TEXT("searchNodeActions")));
Router->BindRoute(FHttpPath(TEXT("/api/spawn-node")), EHttpServerRequestVerbs::VERB_POST,
QueuedHandler(TEXT("spawnNode")));
QueuedHandler(TEXT("spawn_node")));
Router->BindRoute(FHttpPath(TEXT("/api/rename-asset")), EHttpServerRequestVerbs::VERB_POST,
QueuedHandler(TEXT("renameAsset")));
Router->BindRoute(FHttpPath(TEXT("/api/reparent-blueprint")), EHttpServerRequestVerbs::VERB_POST,
@@ -827,6 +841,9 @@ bool FBlueprintMCPServer::Start(int32 InPort, bool bEditorMode)
// Register TMap dispatch handlers
RegisterHandlers();
// Build new-style handler registry from UMCPHandler subclasses
BuildMCPHandlerRegistry();
HttpModule.StartAllListeners();
// Verify the listener actually bound by attempting a TCP connection
@@ -901,7 +918,26 @@ bool FBlueprintMCPServer::ProcessOneRequest()
TSharedRef<FJsonObject> Result = MakeShared<FJsonObject>();
if (FRequestHandler* Handler = HandlerMap.Find(Req->Endpoint))
if (UClass** HandlerClass = MCPHandlerRegistry.Find(Req->Endpoint))
{
const bool bIsMutation = MutationEndpoints.Contains(Req->Endpoint);
if (bIsMutation && GEditor)
{
GEditor->BeginTransaction(FText::FromString(FString::Printf(TEXT("BlueprintMCP: %s"), *Req->Endpoint)));
}
UMCPHandler* Handler = NewObject<UMCPHandler>(GetTransientPackage(), *HandlerClass);
if (PopulateHandlerFromJson(Handler, Params.Get(), &*Result))
{
Handler->Handle(Params.Get(), &*Result);
}
if (bIsMutation && GEditor)
{
GEditor->EndTransaction();
}
}
else if (FRequestHandler* Handler = HandlerMap.Find(Req->Endpoint))
{
// Wrap mutation endpoints in an undo transaction so users can Ctrl+Z
const bool bIsMutation = MutationEndpoints.Contains(Req->Endpoint);
@@ -949,7 +985,7 @@ void FBlueprintMCPServer::RegisterHandlers()
TEXT("deleteNode"),
TEXT("duplicateNodes"),
TEXT("addNode"),
TEXT("spawnNode"),
TEXT("spawn_node"),
TEXT("setNodeComment"),
TEXT("renameAsset"),
TEXT("reparentBlueprint"),
@@ -1032,7 +1068,7 @@ void FBlueprintMCPServer::RegisterHandlers()
H(TEXT("validateAllBlueprints"), &FBlueprintMCPServer::HandleValidateAllBlueprints);
H(TEXT("addNode"), &FBlueprintMCPServer::HandleAddNode);
H(TEXT("searchNodeActions"), &FBlueprintMCPServer::HandleSearchNodeActions);
H(TEXT("spawnNode"), &FBlueprintMCPServer::HandleSpawnNode);
// spawn_node is now handled by UMCPHandler_SpawnNode (new-style registry)
H(TEXT("renameAsset"), &FBlueprintMCPServer::HandleRenameAsset);
H(TEXT("reparentBlueprint"), &FBlueprintMCPServer::HandleReparentBlueprint);
H(TEXT("setBlueprintDefault"), &FBlueprintMCPServer::HandleSetBlueprintDefault);
@@ -1100,6 +1136,35 @@ void FBlueprintMCPServer::RegisterHandlers()
H(TEXT("setStateBlendSpace"), &FBlueprintMCPServer::HandleSetStateBlendSpace);
}
void FBlueprintMCPServer::BuildMCPHandlerRegistry()
{
TArray<UClass*> HandlerClasses;
GetDerivedClasses(UMCPHandler::StaticClass(), HandlerClasses);
for (UClass* Class : HandlerClasses)
{
if (Class->HasAnyClassFlags(CLASS_Abstract))
{
continue;
}
const FString& ToolName = Class->GetMetaData(TEXT("ToolName"));
if (ToolName.IsEmpty())
{
UE_LOG(LogTemp, Warning, TEXT("BlueprintMCP: %s has no ToolName meta — skipping."), *Class->GetName());
continue;
}
if (MCPHandlerRegistry.Contains(ToolName))
{
UE_LOG(LogTemp, Warning, TEXT("BlueprintMCP: Duplicate ToolName '%s' on %s — skipping."), *ToolName, *Class->GetName());
continue;
}
MCPHandlerRegistry.Add(ToolName, Class);
UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Registered handler '%s' → %s"), *ToolName, *Class->GetName());
}
UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: %d new-style handlers registered."), MCPHandlerRegistry.Num());
}
// ============================================================
// HandleRescan — refresh cached asset lists from asset registry
// ============================================================

View File

@@ -0,0 +1,219 @@
#include "MCPHandler.h"
#include "BlueprintMCPServer.h"
#include "Dom/JsonObject.h"
#include "UObject/UnrealType.h"
#include "UObject/EnumProperty.h"
namespace MCPPopulate
{
// Try to set a single FProperty on a handler from a JSON field.
// Returns an empty string on success, or an error message on failure.
FString SetPropertyFromJson(
UMCPHandler* Handler,
FProperty* Prop,
const FString& FieldName,
const FJsonObject* Json)
{
void* ValuePtr = Prop->ContainerPtrToValuePtr<void>(Handler);
// FString
if (FStrProperty* StrProp = CastField<FStrProperty>(Prop))
{
if (!Json->HasTypedField<EJson::String>(FieldName))
{
return FString::Printf(TEXT("'%s' must be a string"), *FieldName);
}
StrProp->SetPropertyValue(ValuePtr, Json->GetStringField(FieldName));
return FString();
}
// int32
if (FIntProperty* IntProp = CastField<FIntProperty>(Prop))
{
if (!Json->HasTypedField<EJson::Number>(FieldName))
{
return FString::Printf(TEXT("'%s' must be a number"), *FieldName);
}
IntProp->SetPropertyValue(ValuePtr, (int32)Json->GetNumberField(FieldName));
return FString();
}
// float
if (FFloatProperty* FloatProp = CastField<FFloatProperty>(Prop))
{
if (!Json->HasTypedField<EJson::Number>(FieldName))
{
return FString::Printf(TEXT("'%s' must be a number"), *FieldName);
}
FloatProp->SetPropertyValue(ValuePtr, (float)Json->GetNumberField(FieldName));
return FString();
}
// double
if (FDoubleProperty* DoubleProp = CastField<FDoubleProperty>(Prop))
{
if (!Json->HasTypedField<EJson::Number>(FieldName))
{
return FString::Printf(TEXT("'%s' must be a number"), *FieldName);
}
DoubleProp->SetPropertyValue(ValuePtr, Json->GetNumberField(FieldName));
return FString();
}
// bool
if (FBoolProperty* BoolProp = CastField<FBoolProperty>(Prop))
{
if (!Json->HasTypedField<EJson::Boolean>(FieldName))
{
return FString::Printf(TEXT("'%s' must be a boolean"), *FieldName);
}
BoolProp->SetPropertyValue(ValuePtr, Json->GetBoolField(FieldName));
return FString();
}
// Enum (FEnumProperty — C++ enum class)
if (FEnumProperty* EnumProp = CastField<FEnumProperty>(Prop))
{
if (!Json->HasTypedField<EJson::String>(FieldName))
{
return FString::Printf(TEXT("'%s' must be a string"), *FieldName);
}
FString ValueStr = Json->GetStringField(FieldName);
UEnum* Enum = EnumProp->GetEnum();
int64 EnumVal = Enum->GetValueByNameString(ValueStr);
if (EnumVal == INDEX_NONE)
{
return FString::Printf(TEXT("'%s': unknown enum value '%s'"), *FieldName, *ValueStr);
}
FNumericProperty* UnderlyingProp = EnumProp->GetUnderlyingProperty();
UnderlyingProp->SetIntPropertyValue(ValuePtr, EnumVal);
return FString();
}
// Enum (FByteProperty with Enum — old-style UENUM)
if (FByteProperty* ByteProp = CastField<FByteProperty>(Prop))
{
if (ByteProp->Enum)
{
if (!Json->HasTypedField<EJson::String>(FieldName))
{
return FString::Printf(TEXT("'%s' must be a string"), *FieldName);
}
FString ValueStr = Json->GetStringField(FieldName);
int64 EnumVal = ByteProp->Enum->GetValueByNameString(ValueStr);
if (EnumVal == INDEX_NONE)
{
return FString::Printf(TEXT("'%s': unknown enum value '%s'"), *FieldName, *ValueStr);
}
ByteProp->SetPropertyValue(ValuePtr, (uint8)EnumVal);
return FString();
}
// Plain byte without enum — treat as number
if (!Json->HasTypedField<EJson::Number>(FieldName))
{
return FString::Printf(TEXT("'%s' must be a number"), *FieldName);
}
ByteProp->SetPropertyValue(ValuePtr, (uint8)Json->GetNumberField(FieldName));
return FString();
}
// FMCPSubtree — stash the JSON subtree into the struct
if (FStructProperty* StructProp = CastField<FStructProperty>(Prop))
{
if (StructProp->Struct == FMCPSubtree::StaticStruct())
{
if (!Json->HasTypedField<EJson::Object>(FieldName))
{
return FString::Printf(TEXT("'%s' must be an object"), *FieldName);
}
FMCPSubtree* Subtree = StructProp->ContainerPtrToValuePtr<FMCPSubtree>(Handler);
Subtree->Json = Json->GetObjectField(FieldName);
return FString();
}
}
return FString::Printf(TEXT("'%s': unsupported property type '%s'"),
*FieldName, *Prop->GetCPPType());
}
// Convert a property name from PascalCase to camelCase, matching JSON conventions.
// e.g. "BlueprintName" -> "blueprintName", "PosX" -> "posX"
FString PropertyNameToJsonKey(const FString& PropName)
{
if (PropName.IsEmpty())
{
return PropName;
}
FString Result = PropName;
Result[0] = FChar::ToLower(Result[0]);
return Result;
}
} // namespace MCPPopulate
bool FBlueprintMCPServer::PopulateHandlerFromJson(
UMCPHandler* Handler,
const FJsonObject* Json,
FJsonObject* Result)
{
UClass* HandlerClass = Handler->GetClass();
// Build a set of known property names (as JSON keys) for the unknown-field check.
TSet<FString> KnownKeys;
TArray<FProperty*> Properties;
for (TFieldIterator<FProperty> It(HandlerClass, EFieldIterationFlags::None); It; ++It)
{
FProperty* Prop = *It;
Properties.Add(Prop);
KnownKeys.Add(MCPPopulate::PropertyNameToJsonKey(Prop->GetName()));
}
// Check for unknown fields in the JSON
for (const auto& KV : Json->Values)
{
if (!KnownKeys.Contains(KV.Key))
{
MakeErrorJson(Result, FString::Printf(
TEXT("Unknown parameter '%s'"), *KV.Key));
Result->SetArrayField(TEXT("validParameters"),
[&]() {
TArray<TSharedPtr<FJsonValue>> Arr;
for (const FString& Key : KnownKeys)
{
Arr.Add(MakeShared<FJsonValueString>(Key));
}
return Arr;
}());
return false;
}
}
// Populate each property from JSON
for (FProperty* Prop : Properties)
{
FString JsonKey = MCPPopulate::PropertyNameToJsonKey(Prop->GetName());
bool bOptional = Prop->HasMetaData(TEXT("Optional"));
if (!Json->HasField(JsonKey))
{
if (!bOptional)
{
MakeErrorJson(Result, FString::Printf(
TEXT("Missing required parameter '%s'"), *JsonKey));
return false;
}
continue;
}
FString Error = MCPPopulate::SetPropertyFromJson(Handler, Prop, JsonKey, Json);
if (!Error.IsEmpty())
{
MakeErrorJson(Result, Error);
return false;
}
}
return true;
}