More MCP work
This commit is contained in:
@@ -15,7 +15,6 @@ The luprex DLL never calls into the Unreal driver. Output goes through polled bu
|
|||||||
- `build.py all` — full rebuild (engine, game, intellisense, project files)
|
- `build.py all` — full rebuild (engine, game, intellisense, project files)
|
||||||
- `build.py c++` — lightweight rebuild (only if you've only edited C++ files in this repo)
|
- `build.py c++` — lightweight rebuild (only if you've only edited C++ files in this repo)
|
||||||
- Lua and Blueprint edits don't require a rebuild.
|
- Lua and Blueprint edits don't require a rebuild.
|
||||||
- Do not run builds yourself. The IDE builds automatically when the user runs the code.
|
|
||||||
|
|
||||||
## Directory Structure
|
## Directory Structure
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
#if WITH_EDITOR
|
|
||||||
|
|
||||||
#include "BlueprintExporter.h"
|
#include "BlueprintExporter.h"
|
||||||
#include "Common.h"
|
|
||||||
#include "Engine/Blueprint.h"
|
#include "Engine/Blueprint.h"
|
||||||
#include "EdGraph/EdGraph.h"
|
#include "EdGraph/EdGraph.h"
|
||||||
#include "EdGraph/EdGraphNode.h"
|
#include "EdGraph/EdGraphNode.h"
|
||||||
@@ -166,7 +163,7 @@ FString FlxBlueprintExporter::FormatPinName(UEdGraphPin *Pin)
|
|||||||
return SanitizedRaw;
|
return SanitizedRaw;
|
||||||
|
|
||||||
// No unambiguous name found.
|
// No unambiguous name found.
|
||||||
UE_LOG(LogLuprexIntegration, Warning, TEXT("Blueprint export: ambiguous pin name '%s' on node '%s'"),
|
UE_LOG(LogTemp, Warning, TEXT("Blueprint export: ambiguous pin name '%s' on node '%s'"),
|
||||||
*Pin->PinName.ToString(), *Node->GetNodeTitle(ENodeTitleType::ListView).ToString());
|
*Pin->PinName.ToString(), *Node->GetNodeTitle(ENodeTitleType::ListView).ToString());
|
||||||
return FString::Printf(TEXT("?%s"), *SanitizedRaw);
|
return FString::Printf(TEXT("?%s"), *SanitizedRaw);
|
||||||
}
|
}
|
||||||
@@ -393,5 +390,3 @@ void FlxBlueprintExporter::EmitNodeList()
|
|||||||
*FormatNodeName(Node), *Node->NodeGuid.ToString());
|
*FormatNodeName(Node), *Node->NodeGuid.ToString());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#endif
|
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
MCPAssetsBase::MCPAssetsBase(UClass* InTargetClass)
|
MCPAssetsBase::MCPAssetsBase(UClass* InTargetClass)
|
||||||
: TargetClass(InTargetClass)
|
: TargetClass(InTargetClass)
|
||||||
{
|
{
|
||||||
|
Scans.Add(InTargetClass);
|
||||||
}
|
}
|
||||||
|
|
||||||
MCPAssetsBase& MCPAssetsBase::Exact(const FString& InName)
|
MCPAssetsBase& MCPAssetsBase::Exact(const FString& InName)
|
||||||
@@ -62,10 +63,8 @@ bool MCPAssetsBase::Info()
|
|||||||
IAssetRegistry& AR = FModuleManager::LoadModuleChecked<FAssetRegistryModule>("AssetRegistry").Get();
|
IAssetRegistry& AR = FModuleManager::LoadModuleChecked<FAssetRegistryModule>("AssetRegistry").Get();
|
||||||
while (AR.IsLoadingAssets()) FPlatformProcess::Sleep(0.1f);
|
while (AR.IsLoadingAssets()) FPlatformProcess::Sleep(0.1f);
|
||||||
|
|
||||||
FARFilter Filter;
|
|
||||||
TArray<FAssetData> Candidates;
|
TArray<FAssetData> Candidates;
|
||||||
ConfigureFilterClassPaths(Filter);
|
AR.GetAssets(ConfigureFilter(), Candidates);
|
||||||
AR.GetAssets(Filter, Candidates);
|
|
||||||
for (const FAssetData &Data : Candidates)
|
for (const FAssetData &Data : Candidates)
|
||||||
{
|
{
|
||||||
if (AssetMatches(Data)) AssetResults.Add(Data);
|
if (AssetMatches(Data)) AssetResults.Add(Data);
|
||||||
@@ -117,22 +116,17 @@ bool MCPAssetsBase::Load()
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
void MCPAssetsBase::ConfigureFilterClassPaths(FARFilter &Filter)
|
FARFilter MCPAssetsBase::ConfigureFilter()
|
||||||
{
|
{
|
||||||
if (Classes.IsEmpty())
|
FARFilter Filter;
|
||||||
{
|
for (UClass* C : Scans) Filter.ClassPaths.Add(C->GetClassPathName());
|
||||||
Filter.ClassPaths.Add(TargetClass->GetClassPathName());
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
for (UClass* C : Classes) Filter.ClassPaths.Add(C->GetClassPathName());
|
|
||||||
}
|
|
||||||
Filter.bRecursiveClasses = !bNoDerived;
|
Filter.bRecursiveClasses = !bNoDerived;
|
||||||
if (!bAllContent)
|
if (!bAllContent)
|
||||||
{
|
{
|
||||||
Filter.PackagePaths.Add(FName(TEXT("/Game")));
|
Filter.PackagePaths.Add(FName(TEXT("/Game")));
|
||||||
Filter.bRecursivePaths = true;
|
Filter.bRecursivePaths = true;
|
||||||
}
|
}
|
||||||
|
return Filter;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool MCPAssetsBase::AssetMatches(const FAssetData &Asset)
|
bool MCPAssetsBase::AssetMatches(const FAssetData &Asset)
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
#include "MCPEditorSubsystem.h"
|
#include "MCPEditorSubsystem.h"
|
||||||
#include "MCPServer.h"
|
#include "MCPServer.h"
|
||||||
|
#include "BlueprintExporter.h"
|
||||||
|
#include "Engine/Blueprint.h"
|
||||||
|
#include "EdGraph/EdGraph.h"
|
||||||
|
#include "Exporters/Exporter.h"
|
||||||
|
#include "UnrealExporter.h"
|
||||||
|
#include "Misc/FileHelper.h"
|
||||||
|
|
||||||
void UBlueprintMCPEditorSubsystem::Initialize(FSubsystemCollectionBase& Collection)
|
void UBlueprintMCPEditorSubsystem::Initialize(FSubsystemCollectionBase& Collection)
|
||||||
{
|
{
|
||||||
@@ -11,6 +17,9 @@ void UBlueprintMCPEditorSubsystem::Initialize(FSubsystemCollectionBase& Collecti
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
OnAssetSavedHandle = UPackage::PackageSavedWithContextEvent.AddUObject(
|
||||||
|
this, &UBlueprintMCPEditorSubsystem::OnAssetSaved);
|
||||||
|
|
||||||
Server = MakeUnique<FBlueprintMCPServer>();
|
Server = MakeUnique<FBlueprintMCPServer>();
|
||||||
if (Server->Start(9847, /*bEditorMode=*/true))
|
if (Server->Start(9847, /*bEditorMode=*/true))
|
||||||
{
|
{
|
||||||
@@ -25,6 +34,8 @@ void UBlueprintMCPEditorSubsystem::Initialize(FSubsystemCollectionBase& Collecti
|
|||||||
|
|
||||||
void UBlueprintMCPEditorSubsystem::Deinitialize()
|
void UBlueprintMCPEditorSubsystem::Deinitialize()
|
||||||
{
|
{
|
||||||
|
UPackage::PackageSavedWithContextEvent.Remove(OnAssetSavedHandle);
|
||||||
|
|
||||||
if (Server)
|
if (Server)
|
||||||
{
|
{
|
||||||
Server->Stop();
|
Server->Stop();
|
||||||
@@ -52,3 +63,47 @@ TStatId UBlueprintMCPEditorSubsystem::GetStatId() const
|
|||||||
{
|
{
|
||||||
RETURN_QUICK_DECLARE_CYCLE_STAT(UBlueprintMCPEditorSubsystem, STATGROUP_Tickables);
|
RETURN_QUICK_DECLARE_CYCLE_STAT(UBlueprintMCPEditorSubsystem, STATGROUP_Tickables);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void UBlueprintMCPEditorSubsystem::OnAssetSaved(const FString& PackageFilename, UPackage* Package, FObjectPostSaveContext Context)
|
||||||
|
{
|
||||||
|
if (!Package) return;
|
||||||
|
|
||||||
|
FString PkDir = FPaths::ProjectDir() / TEXT("Saved") / TEXT("BlueprintExports") / FPaths::GetBaseFilename(PackageFilename);
|
||||||
|
IFileManager::Get().DeleteDirectory(*PkDir, false, true);
|
||||||
|
|
||||||
|
// Export the whole package in both formats for comparison.
|
||||||
|
{
|
||||||
|
FStringOutputDevice Archive;
|
||||||
|
const FExportObjectInnerContext InnerContext;
|
||||||
|
UExporter::ExportToOutputDevice(&InnerContext, Package, nullptr, Archive, TEXT("copy"), 0);
|
||||||
|
FFileHelper::SaveStringToFile(Archive, *(PkDir / TEXT("COPY_DUMP.txt")));
|
||||||
|
}
|
||||||
|
{
|
||||||
|
FStringOutputDevice Archive;
|
||||||
|
const FExportObjectInnerContext InnerContext;
|
||||||
|
UExporter::ExportToOutputDevice(&InnerContext, Package, nullptr, Archive, TEXT("t3d"), 0);
|
||||||
|
FFileHelper::SaveStringToFile(Archive, *(PkDir / TEXT("T3D_DUMP.txt")));
|
||||||
|
}
|
||||||
|
|
||||||
|
TArray<UObject*> AllObjects;
|
||||||
|
GetObjectsWithPackage(Package, AllObjects);
|
||||||
|
for (UObject *Obj : AllObjects)
|
||||||
|
{
|
||||||
|
if (UBlueprint *BP = Cast<UBlueprint>(Obj))
|
||||||
|
{
|
||||||
|
FString BPDir = PkDir / BP->GetName();
|
||||||
|
TArray<UEdGraph*> AllGraphs;
|
||||||
|
BP->GetAllGraphs(AllGraphs);
|
||||||
|
for (UEdGraph* Graph : AllGraphs)
|
||||||
|
{
|
||||||
|
FlxBlueprintExporter Exporter(Graph);
|
||||||
|
|
||||||
|
FString FilePath = BPDir / Graph->GetName() + TEXT(".txt");
|
||||||
|
FString DetailsPath = BPDir / TEXT("DETAILS") / Graph->GetName() + TEXT(".txt");
|
||||||
|
FFileHelper::SaveStringToFile(Exporter.GetOutput(), *FilePath);
|
||||||
|
FFileHelper::SaveStringToFile(Exporter.GetDetails(), *DetailsPath);
|
||||||
|
UE_LOG(LogTemp, Warning, TEXT("Blueprint export: %s"), *FilePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -597,9 +597,9 @@ public:
|
|||||||
return TEXT("List all available commands with their descriptions.");
|
return TEXT("List all available commands with their descriptions.");
|
||||||
}
|
}
|
||||||
|
|
||||||
virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override
|
// Collect all handler classes sorted by tool name.
|
||||||
|
TArray<TPair<FString, UClass*>> CollectHandlers() const
|
||||||
{
|
{
|
||||||
// Collect all handler classes sorted by tool name
|
|
||||||
TArray<TPair<FString, UClass*>> Handlers;
|
TArray<TPair<FString, UClass*>> Handlers;
|
||||||
for (TObjectIterator<UClass> It; It; ++It)
|
for (TObjectIterator<UClass> It; It; ++It)
|
||||||
{
|
{
|
||||||
@@ -612,84 +612,35 @@ public:
|
|||||||
Handlers.Add({ToolName, Class});
|
Handlers.Add({ToolName, Class});
|
||||||
}
|
}
|
||||||
Handlers.Sort();
|
Handlers.Sort();
|
||||||
|
return Handlers;
|
||||||
|
}
|
||||||
|
|
||||||
if (!Verbose)
|
virtual void Handle(const FJsonObject* Json, FStringBuilderBase& Result) override
|
||||||
{
|
{
|
||||||
// Compact format: "command_name(param1,param2,?optional)"
|
auto Handlers = CollectHandlers();
|
||||||
TArray<TSharedPtr<FJsonValue>> Lines;
|
Result.Appendf(TEXT("%d commands:\n"), Handlers.Num());
|
||||||
for (const auto& Pair : Handlers)
|
|
||||||
{
|
|
||||||
FString Line = Pair.Key + TEXT("(");
|
|
||||||
bool bFirst = true;
|
|
||||||
for (TFieldIterator<FProperty> PropIt(Pair.Value, EFieldIterationFlags::None); PropIt; ++PropIt)
|
|
||||||
{
|
|
||||||
if (!bFirst) Line += TEXT(",");
|
|
||||||
bFirst = false;
|
|
||||||
if (PropIt->HasMetaData(TEXT("Optional"))) Line += TEXT("?");
|
|
||||||
Line += MCPUtils::PropertyNameToJsonKey(PropIt->GetName());
|
|
||||||
}
|
|
||||||
Line += TEXT(")");
|
|
||||||
Lines.Add(MakeShared<FJsonValueString>(Line));
|
|
||||||
}
|
|
||||||
Result->SetNumberField(TEXT("count"), Lines.Num());
|
|
||||||
Result->SetArrayField(TEXT("commands"), Lines);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
TArray<TSharedPtr<FJsonValue>> CommandsArray;
|
|
||||||
for (const auto& Pair : Handlers)
|
for (const auto& Pair : Handlers)
|
||||||
{
|
{
|
||||||
|
if (Verbose)
|
||||||
|
{
|
||||||
|
MCPUtils::FormatCommandHelp(Pair.Value, Result);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-verbose: just the signature line
|
||||||
UClass* Class = Pair.Value;
|
UClass* Class = Pair.Value;
|
||||||
const IMCPHandler* Handler = Cast<IMCPHandler>(Class->GetDefaultObject());
|
Result.Append(Pair.Key);
|
||||||
|
Result.Append(TEXT("("));
|
||||||
TSharedRef<FJsonObject> Entry = MakeShared<FJsonObject>();
|
bool bFirst = true;
|
||||||
Entry->SetStringField(TEXT("command"), Pair.Key);
|
|
||||||
Entry->SetStringField(TEXT("description"), Handler->GetDescription());
|
|
||||||
|
|
||||||
// Document parameters from UPROPERTY fields
|
|
||||||
TArray<TSharedPtr<FJsonValue>> ParamsArray;
|
|
||||||
for (TFieldIterator<FProperty> PropIt(Class, EFieldIterationFlags::None); PropIt; ++PropIt)
|
for (TFieldIterator<FProperty> PropIt(Class, EFieldIterationFlags::None); PropIt; ++PropIt)
|
||||||
{
|
{
|
||||||
FProperty* Prop = *PropIt;
|
if (!bFirst) Result.Append(TEXT(","));
|
||||||
TSharedRef<FJsonObject> ParamObj = MakeShared<FJsonObject>();
|
bFirst = false;
|
||||||
ParamObj->SetStringField(TEXT("name"), MCPUtils::PropertyNameToJsonKey(Prop->GetName()));
|
if (PropIt->HasMetaData(TEXT("Optional"))) Result.Append(TEXT("?"));
|
||||||
ParamObj->SetBoolField(TEXT("required"), !Prop->HasMetaData(TEXT("Optional")));
|
Result.Append(MCPUtils::PropertyNameToJsonKey(PropIt->GetName()));
|
||||||
|
|
||||||
// Type
|
|
||||||
if (CastField<FStrProperty>(Prop))
|
|
||||||
ParamObj->SetStringField(TEXT("type"), TEXT("string"));
|
|
||||||
else if (CastField<FIntProperty>(Prop))
|
|
||||||
ParamObj->SetStringField(TEXT("type"), TEXT("integer"));
|
|
||||||
else if (CastField<FFloatProperty>(Prop) || CastField<FDoubleProperty>(Prop))
|
|
||||||
ParamObj->SetStringField(TEXT("type"), TEXT("number"));
|
|
||||||
else if (CastField<FBoolProperty>(Prop))
|
|
||||||
ParamObj->SetStringField(TEXT("type"), TEXT("boolean"));
|
|
||||||
else if (FStructProperty* SP = CastField<FStructProperty>(Prop))
|
|
||||||
{
|
|
||||||
FString StructName = SP->Struct->GetName();
|
|
||||||
StructName.ReplaceInline(TEXT("MCP"), TEXT(""));
|
|
||||||
ParamObj->SetStringField(TEXT("type"), StructName);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
ParamObj->SetStringField(TEXT("type"), TEXT("string"));
|
|
||||||
|
|
||||||
// Description from metadata
|
|
||||||
const FString& Desc = Prop->GetMetaData(TEXT("Description"));
|
|
||||||
if (!Desc.IsEmpty())
|
|
||||||
{
|
|
||||||
ParamObj->SetStringField(TEXT("description"), Desc);
|
|
||||||
}
|
|
||||||
|
|
||||||
ParamsArray.Add(MakeShared<FJsonValueObject>(ParamObj));
|
|
||||||
}
|
}
|
||||||
if (ParamsArray.Num() > 0)
|
Result.Append(TEXT(")\n"));
|
||||||
{
|
|
||||||
Entry->SetArrayField(TEXT("parameters"), ParamsArray);
|
|
||||||
}
|
|
||||||
|
|
||||||
CommandsArray.Add(MakeShared<FJsonValueObject>(Entry));
|
|
||||||
}
|
}
|
||||||
Result->SetNumberField(TEXT("count"), CommandsArray.Num());
|
|
||||||
Result->SetArrayField(TEXT("commands"), CommandsArray);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -38,101 +38,54 @@ public:
|
|||||||
UPROPERTY(meta=(Optional, Description="Filter by parent class name (substring match)"))
|
UPROPERTY(meta=(Optional, Description="Filter by parent class name (substring match)"))
|
||||||
FString ParentClass;
|
FString ParentClass;
|
||||||
|
|
||||||
UPROPERTY(meta=(Optional, Description="Type filter: 'all' (default), 'regular', or 'level'"))
|
UPROPERTY(meta=(Optional, Description="Include regular blueprints (default true)"))
|
||||||
FString Type;
|
bool IncludeRegular = true;
|
||||||
|
|
||||||
|
UPROPERTY(meta=(Optional, Description="Include regular blueprints (default true)"))
|
||||||
|
bool IncludeLevel = true;
|
||||||
|
|
||||||
virtual FString GetDescription() const override
|
virtual FString GetDescription() const override
|
||||||
{
|
{
|
||||||
return TEXT("List all Blueprint assets in the project, with optional filtering by name, parent class, or type.");
|
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
|
virtual void Handle(const FJsonObject* Json, FStringBuilderBase& Result) override
|
||||||
{
|
{
|
||||||
// type: "all" (default), "regular", "level"
|
MCPAssets<UObject> Assets;
|
||||||
bool bIncludeRegular = Type.IsEmpty() || Type == TEXT("all") || Type == TEXT("regular");
|
Assets.NoScans().Substring(Filter).Limit(500);
|
||||||
bool bIncludeLevel = Type.IsEmpty() || Type == TEXT("all") || Type == TEXT("level");
|
if (IncludeRegular) Assets.Scan<UBlueprint>();
|
||||||
|
if (IncludeLevel) Assets.Scan<UWorld>();
|
||||||
MCPAssets<UBlueprint> AllBlueprints;
|
Assets.Info();
|
||||||
AllBlueprints.Info();
|
for (const FAssetData& Asset : Assets.AllData())
|
||||||
MCPAssets<UWorld> AllWorlds;
|
|
||||||
AllWorlds.Info();
|
|
||||||
|
|
||||||
TArray<TSharedPtr<FJsonValue>> Entries;
|
|
||||||
if (bIncludeRegular)
|
|
||||||
for (const FAssetData& Asset : AllBlueprints.AllData())
|
|
||||||
{
|
{
|
||||||
FString Name = Asset.AssetName.ToString();
|
// Extract parent class name
|
||||||
FString Path = Asset.PackageName.ToString();
|
|
||||||
|
|
||||||
if (!Filter.IsEmpty())
|
|
||||||
{
|
|
||||||
if (!Name.Contains(Filter, ESearchCase::IgnoreCase) &&
|
|
||||||
!Path.Contains(Filter, ESearchCase::IgnoreCase))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
FString ParentClassName;
|
FString ParentClassName;
|
||||||
Asset.GetTagValue(FName(TEXT("ParentClass")), ParentClassName);
|
if (Asset.AssetClassPath == UWorld::StaticClass()->GetClassPathName())
|
||||||
// Tag stores full path — extract short name
|
|
||||||
int32 DotIndex;
|
|
||||||
if (ParentClassName.FindLastChar('.', DotIndex))
|
|
||||||
{
|
{
|
||||||
ParentClassName = ParentClassName.Mid(DotIndex + 1);
|
ParentClassName = TEXT("LevelScriptActor");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Asset.GetTagValue(FName(TEXT("ParentClass")), ParentClassName);
|
||||||
|
int32 DotIndex;
|
||||||
|
if (ParentClassName.FindLastChar('.', DotIndex))
|
||||||
|
{
|
||||||
|
ParentClassName = ParentClassName.Mid(DotIndex + 1);
|
||||||
|
}
|
||||||
|
ParentClassName.RemoveFromEnd(TEXT("'"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply parent class filter
|
||||||
if (!ParentClass.IsEmpty())
|
if (!ParentClass.IsEmpty())
|
||||||
{
|
{
|
||||||
if (!ParentClassName.Contains(ParentClass, ESearchCase::IgnoreCase))
|
if (!ParentClassName.Equals(ParentClass, ESearchCase::IgnoreCase))
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
TSharedRef<FJsonObject> Entry = MakeShared<FJsonObject>();
|
Result.Appendf(TEXT("%30s %s\n"), *ParentClassName, *Asset.PackageName.ToString());
|
||||||
Entry->SetStringField(TEXT("name"), Name);
|
|
||||||
Entry->SetStringField(TEXT("path"), Path);
|
|
||||||
Entry->SetStringField(TEXT("parentClass"), ParentClassName);
|
|
||||||
Entries.Add(MakeShared<FJsonValueObject>(Entry));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also include level blueprints from maps
|
|
||||||
if (bIncludeLevel)
|
|
||||||
for (const FAssetData& Asset : AllWorlds.AllData())
|
|
||||||
{
|
|
||||||
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<FJsonObject> Entry = MakeShared<FJsonObject>();
|
|
||||||
Entry->SetStringField(TEXT("name"), Name);
|
|
||||||
Entry->SetStringField(TEXT("path"), Path);
|
|
||||||
Entry->SetStringField(TEXT("parentClass"), TEXT("LevelScriptActor"));
|
|
||||||
Entry->SetBoolField(TEXT("isLevelBlueprint"), true);
|
|
||||||
Entries.Add(MakeShared<FJsonValueObject>(Entry));
|
|
||||||
}
|
|
||||||
|
|
||||||
Result->SetNumberField(TEXT("count"), Entries.Num());
|
|
||||||
Result->SetNumberField(TEXT("total"), AllBlueprints.AllData().Num() + AllWorlds.AllData().Num());
|
|
||||||
Result->SetArrayField(TEXT("blueprints"), Entries);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -112,158 +112,42 @@ FMCPServer* FMCPServer::Get()
|
|||||||
return Sub ? Sub->GetServer() : nullptr;
|
return Sub ? Sub->GetServer() : nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
FString FMCPServer::DispatchToolCall(const FString& ToolName, const FJsonObject* Params)
|
||||||
// ============================================================
|
|
||||||
// SEH wrappers for crash-safe compilation and saving.
|
|
||||||
// MSVC constraint: __try/__except functions must NOT contain C++
|
|
||||||
// objects with destructors. We factor the actual work into
|
|
||||||
// separate "inner" functions and only do try/except in thin wrappers.
|
|
||||||
// ============================================================
|
|
||||||
#if PLATFORM_WINDOWS
|
|
||||||
|
|
||||||
// Inner functions that do the actual C++ work (may have destructors)
|
|
||||||
static void CompileBlueprintInner(UBlueprint* BP, EBlueprintCompileOptions Opts)
|
|
||||||
{
|
{
|
||||||
FKismetEditorUtilities::CompileBlueprint(BP, Opts, nullptr);
|
UClass** HandlerClass = MCPHandlerRegistry.Find(ToolName);
|
||||||
}
|
if (!HandlerClass)
|
||||||
|
|
||||||
static ESavePackageResult SavePackageInner(
|
|
||||||
UPackage* Package, UObject* Asset, const TCHAR* Filename,
|
|
||||||
FSavePackageArgs* SaveArgs)
|
|
||||||
{
|
|
||||||
FSavePackageResultStruct Result = UPackage::Save(Package, Asset, Filename, *SaveArgs);
|
|
||||||
return Result.Result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// SEH wrappers — absolutely NO C++ objects with destructors here.
|
|
||||||
// EXCEPTION_EXECUTE_HANDLER = 1 (avoiding Windows.h include)
|
|
||||||
#pragma warning(push)
|
|
||||||
#pragma warning(disable: 4611) // interaction between '_setjmp' and C++ object destruction
|
|
||||||
int32 TryCompileBlueprintSEH(UBlueprint* BP, EBlueprintCompileOptions Opts)
|
|
||||||
{
|
|
||||||
__try
|
|
||||||
{
|
{
|
||||||
CompileBlueprintInner(BP, Opts);
|
return FString::Printf(TEXT("Unknown tool: %s"), *ToolName);
|
||||||
return 0;
|
|
||||||
}
|
}
|
||||||
__except (1)
|
|
||||||
|
TStrongObjectPtr<UObject> HandlerObj(NewObject<UObject>(GetTransientPackage(), *HandlerClass));
|
||||||
|
IMCPHandler* Handler = Cast<IMCPHandler>(HandlerObj.Get());
|
||||||
|
|
||||||
|
TStringBuilder<4096> PopulateError;
|
||||||
|
if (!MCPUtils::PopulateFromJson(HandlerObj->GetClass(), HandlerObj.Get(), Params, PopulateError))
|
||||||
{
|
{
|
||||||
return -1;
|
PopulateError.Append(TEXT("\nUsage:\n"));
|
||||||
|
MCPUtils::FormatCommandHelp(*HandlerClass, PopulateError);
|
||||||
|
return PopulateError.ToString();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
static int32 TrySavePackageSEH(
|
// Try text handler first; fall back to JSON if nothing was written.
|
||||||
UPackage* Package, UObject* Asset, const TCHAR* Filename,
|
TStringBuilder<32768> TextResult;
|
||||||
FSavePackageArgs* SaveArgs, ESavePackageResult* OutResult)
|
Handler->Handle(Params, TextResult);
|
||||||
{
|
if (TextResult.Len() > 0)
|
||||||
__try
|
|
||||||
{
|
{
|
||||||
*OutResult = SavePackageInner(Package, Asset, Filename, SaveArgs);
|
FString Result = TextResult.ToString();
|
||||||
return 0;
|
for (int32 i = 0; i < Result.Len(); ++i)
|
||||||
}
|
|
||||||
__except (1)
|
|
||||||
{
|
|
||||||
*OutResult = ESavePackageResult::Error;
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Inner: create expression, register in material, and trigger PostEditChange.
|
|
||||||
// All of this may crash for classes that are effectively abstract.
|
|
||||||
static void AddMaterialExpressionInner(
|
|
||||||
UObject* Owner, UClass* ExprClass, UMaterial* Material, UMaterialFunction* MatFunc,
|
|
||||||
int32 PosX, int32 PosY, UMaterialExpression** OutExpr)
|
|
||||||
{
|
|
||||||
*OutExpr = NewObject<UMaterialExpression>(Owner, ExprClass);
|
|
||||||
if (!*OutExpr) return;
|
|
||||||
|
|
||||||
(*OutExpr)->MaterialExpressionEditorX = PosX;
|
|
||||||
(*OutExpr)->MaterialExpressionEditorY = PosY;
|
|
||||||
|
|
||||||
if (Material)
|
|
||||||
{
|
|
||||||
Material->GetExpressionCollection().AddExpression(*OutExpr);
|
|
||||||
if (Material->MaterialGraph)
|
|
||||||
{
|
{
|
||||||
Material->MaterialGraph->RebuildGraph();
|
if (Result[i] == TEXT('\0')) Result[i] = TEXT(' ');
|
||||||
}
|
}
|
||||||
Material->PreEditChange(nullptr);
|
return Result;
|
||||||
Material->PostEditChange();
|
|
||||||
Material->MarkPackageDirty();
|
|
||||||
}
|
}
|
||||||
else if (MatFunc)
|
|
||||||
{
|
|
||||||
MatFunc->GetExpressionCollection().AddExpression(*OutExpr);
|
|
||||||
MatFunc->PreEditChange(nullptr);
|
|
||||||
MatFunc->PostEditChange();
|
|
||||||
MatFunc->MarkPackageDirty();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Inner: remove a bad expression from a material after a crash
|
// Invoke the Json handler.
|
||||||
static void CleanupBadExpressionInner(UMaterial* Material, UMaterialFunction* MatFunc, UMaterialExpression* BadExpr)
|
TSharedRef<FJsonObject> JsonResult = MakeShared<FJsonObject>();
|
||||||
{
|
Handler->Handle(Params, &*JsonResult);
|
||||||
if (!BadExpr) return;
|
return MCPUtils::JsonToString(JsonResult);
|
||||||
if (Material)
|
|
||||||
{
|
|
||||||
Material->GetExpressionCollection().RemoveExpression(BadExpr);
|
|
||||||
if (Material->MaterialGraph)
|
|
||||||
{
|
|
||||||
Material->MaterialGraph->RebuildGraph();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (MatFunc)
|
|
||||||
{
|
|
||||||
MatFunc->GetExpressionCollection().RemoveExpression(BadExpr);
|
|
||||||
}
|
|
||||||
BadExpr->MarkAsGarbage();
|
|
||||||
}
|
|
||||||
|
|
||||||
int32 TryAddMaterialExpressionSEH(
|
|
||||||
UObject* Owner, UClass* ExprClass, UMaterial* Material, UMaterialFunction* MatFunc,
|
|
||||||
int32 PosX, int32 PosY, UMaterialExpression** OutExpr)
|
|
||||||
{
|
|
||||||
__try
|
|
||||||
{
|
|
||||||
AddMaterialExpressionInner(Owner, ExprClass, Material, MatFunc, PosX, PosY, OutExpr);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
__except (1)
|
|
||||||
{
|
|
||||||
// Try to clean up the partially-added expression
|
|
||||||
__try
|
|
||||||
{
|
|
||||||
CleanupBadExpressionInner(Material, MatFunc, *OutExpr);
|
|
||||||
}
|
|
||||||
__except (1)
|
|
||||||
{
|
|
||||||
// Cleanup also crashed — nothing more we can do
|
|
||||||
}
|
|
||||||
*OutExpr = nullptr;
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#pragma warning(pop)
|
|
||||||
|
|
||||||
#endif // PLATFORM_WINDOWS
|
|
||||||
|
|
||||||
|
|
||||||
void FMCPServer::DispatchToolCall(const FString& ToolName, const FJsonObject* Params, FJsonObject* Result)
|
|
||||||
{
|
|
||||||
if (UClass** HandlerClass = MCPHandlerRegistry.Find(ToolName))
|
|
||||||
{
|
|
||||||
TStrongObjectPtr<UObject> HandlerObj(NewObject<UObject>(GetTransientPackage(), *HandlerClass));
|
|
||||||
IMCPHandler* Handler = Cast<IMCPHandler>(HandlerObj.Get());
|
|
||||||
if (MCPUtils::PopulateFromJson(HandlerObj->GetClass(), HandlerObj.Get(), Params, Result))
|
|
||||||
{
|
|
||||||
Handler->Handle(Params, Result);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Unknown tool: %s"), *ToolName));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -289,9 +173,7 @@ FString FMCPServer::HandleRequest(const FString& Line)
|
|||||||
}
|
}
|
||||||
Request->RemoveField(TEXT("command"));
|
Request->RemoveField(TEXT("command"));
|
||||||
|
|
||||||
TSharedRef<FJsonObject> Result = MakeShared<FJsonObject>();
|
return DispatchToolCall(Command, Request.Get());
|
||||||
DispatchToolCall(Command, Request.Get(), &*Result);
|
|
||||||
return MCPUtils::JsonToString(Result);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -482,11 +364,10 @@ void FMCPServer::ClientThreadFunc(FMCPServer* Server, TSharedPtr<FClientConnecti
|
|||||||
// Empty response means either notification or shutdown
|
// Empty response means either notification or shutdown
|
||||||
if (Response.IsEmpty()) continue;
|
if (Response.IsEmpty()) continue;
|
||||||
|
|
||||||
// Write the response line back (blocking)
|
// Write the response back, null-terminated (blocking)
|
||||||
FString ResponseLine = Response + TEXT("\n");
|
FTCHARToUTF8 Utf8(*Response);
|
||||||
FTCHARToUTF8 Utf8(*ResponseLine);
|
|
||||||
int32 BytesSent = 0;
|
int32 BytesSent = 0;
|
||||||
Socket->Send((const uint8*)Utf8.Get(), Utf8.Length(), BytesSent);
|
Socket->Send((const uint8*)Utf8.Get(), Utf8.Length() + 1, BytesSent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -94,6 +94,10 @@ MCPErrorCallback::MCPErrorCallback(FJsonObject* Result)
|
|||||||
: Func([Result](const FString& Msg) { MCPUtils::MakeErrorJson(Result, Msg); })
|
: Func([Result](const FString& Msg) { MCPUtils::MakeErrorJson(Result, Msg); })
|
||||||
{}
|
{}
|
||||||
|
|
||||||
|
MCPErrorCallback::MCPErrorCallback(FStringBuilderBase& OutResult)
|
||||||
|
: Func([&OutResult](const FString& Msg) { OutResult.Reset(); OutResult.Appendf(TEXT("ERROR: %s\n"), *Msg); })
|
||||||
|
{}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// JSON helpers
|
// JSON helpers
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -1494,3 +1498,64 @@ bool MCPUtils::PopulateFromJson(
|
|||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// FormatPropertyType — human-readable type name for a UPROPERTY
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
FString MCPUtils::FormatPropertyType(FProperty* Prop)
|
||||||
|
{
|
||||||
|
if (CastField<FStrProperty>(Prop)) return TEXT("string");
|
||||||
|
if (CastField<FIntProperty>(Prop)) return TEXT("integer");
|
||||||
|
if (CastField<FFloatProperty>(Prop) || CastField<FDoubleProperty>(Prop)) return TEXT("number");
|
||||||
|
if (CastField<FBoolProperty>(Prop)) return TEXT("boolean");
|
||||||
|
if (FStructProperty* SP = CastField<FStructProperty>(Prop))
|
||||||
|
{
|
||||||
|
FString StructName = SP->Struct->GetName();
|
||||||
|
StructName.ReplaceInline(TEXT("MCP"), TEXT(""));
|
||||||
|
return StructName;
|
||||||
|
}
|
||||||
|
return TEXT("string");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// FormatCommandHelp — verbose description of one handler command
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
void MCPUtils::FormatCommandHelp(UClass* HandlerClass, FStringBuilderBase& Result)
|
||||||
|
{
|
||||||
|
const IMCPHandler* Handler = Cast<IMCPHandler>(HandlerClass->GetDefaultObject());
|
||||||
|
if (!Handler) return;
|
||||||
|
|
||||||
|
const FString& ToolName = HandlerClass->GetMetaData(TEXT("ToolName"));
|
||||||
|
|
||||||
|
// Command signature line
|
||||||
|
Result.Append(ToolName);
|
||||||
|
Result.Append(TEXT("("));
|
||||||
|
bool bFirst = true;
|
||||||
|
for (TFieldIterator<FProperty> PropIt(HandlerClass, EFieldIterationFlags::None); PropIt; ++PropIt)
|
||||||
|
{
|
||||||
|
if (!bFirst) Result.Append(TEXT(","));
|
||||||
|
bFirst = false;
|
||||||
|
if (PropIt->HasMetaData(TEXT("Optional"))) Result.Append(TEXT("?"));
|
||||||
|
Result.Append(PropertyNameToJsonKey(PropIt->GetName()));
|
||||||
|
}
|
||||||
|
Result.Append(TEXT(")\n"));
|
||||||
|
|
||||||
|
// Description and parameter details
|
||||||
|
Result.Appendf(TEXT(" %s\n"), *Handler->GetDescription());
|
||||||
|
for (TFieldIterator<FProperty> PropIt(HandlerClass, EFieldIterationFlags::None); PropIt; ++PropIt)
|
||||||
|
{
|
||||||
|
FProperty* Prop = *PropIt;
|
||||||
|
FString Name = PropertyNameToJsonKey(Prop->GetName());
|
||||||
|
FString Type = FormatPropertyType(Prop);
|
||||||
|
bool bOptional = Prop->HasMetaData(TEXT("Optional"));
|
||||||
|
const FString& Desc = Prop->GetMetaData(TEXT("Description"));
|
||||||
|
|
||||||
|
Result.Appendf(TEXT(" %s %s%s"),
|
||||||
|
*Type, *Name, bOptional ? TEXT(" (optional)") : TEXT(""));
|
||||||
|
if (!Desc.IsEmpty())
|
||||||
|
Result.Appendf(TEXT(" — %s"), *Desc);
|
||||||
|
Result.Append(TEXT("\n"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#if WITH_EDITOR
|
|
||||||
|
|
||||||
#include "CoreMinimal.h"
|
#include "CoreMinimal.h"
|
||||||
#include "Engine/Blueprint.h"
|
#include "Engine/Blueprint.h"
|
||||||
#include "EdGraph/EdGraph.h"
|
#include "EdGraph/EdGraph.h"
|
||||||
@@ -124,5 +122,3 @@ private:
|
|||||||
TStringBuilder<4096> Output;
|
TStringBuilder<4096> Output;
|
||||||
TStringBuilder<4096> Details;
|
TStringBuilder<4096> Details;
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif
|
|
||||||
@@ -9,15 +9,93 @@
|
|||||||
|
|
||||||
struct FARFilter;
|
struct FARFilter;
|
||||||
|
|
||||||
// ============================================================
|
////////////////////////////////////////////////////////////
|
||||||
// MCPAssetsBase — non-template base for MCPAssets<T>
|
//
|
||||||
// ============================================================
|
// MCPAssets - search for assets.
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// Construct an object of class MCPAssets like this:
|
||||||
|
//
|
||||||
|
// MCPAssets<UBlueprint> Assets;
|
||||||
|
//
|
||||||
|
// The UBlueprint template parameter means that this example
|
||||||
|
// Assets loader is capable of storing pointers to
|
||||||
|
// UBlueprint.
|
||||||
|
//
|
||||||
|
// It also means that by default, it will scan
|
||||||
|
// all UBlueprint assets. You can narrow that:
|
||||||
|
//
|
||||||
|
// Assets.NoScans();
|
||||||
|
// Assets.Scan<UAnimBlueprint>();
|
||||||
|
// Assets.Scan<ULuprexBlueprint>();
|
||||||
|
//
|
||||||
|
// To get string matching, call either 'Exact' or
|
||||||
|
// 'Substring'. If you don't call either of these, there's
|
||||||
|
// no string filter. If the string you pass in contains a
|
||||||
|
// slash, then the search is by asset-path, otherwise, by
|
||||||
|
// asset-name:
|
||||||
|
//
|
||||||
|
// Assets.Substring(TEXT("MyAsset"));
|
||||||
|
//
|
||||||
|
// By default, the asset finder limits itself to assets in
|
||||||
|
// the /Game folder. You can expand that:
|
||||||
|
//
|
||||||
|
// Assets.AllContent();
|
||||||
|
//
|
||||||
|
// You can specify that you don't want to see derived
|
||||||
|
// classes:
|
||||||
|
//
|
||||||
|
// Assets.NoDerived()
|
||||||
|
//
|
||||||
|
// You can specify a limit on the number of results
|
||||||
|
// returned:
|
||||||
|
//
|
||||||
|
// Assets.Limit(100)
|
||||||
|
//
|
||||||
|
// You can specify what constitutes an error condition. If
|
||||||
|
// the asset finder detects an error, then it will report
|
||||||
|
// it:
|
||||||
|
//
|
||||||
|
// Assets.ENone() - it's an error if nothing is found
|
||||||
|
// Assets.EAny() - it's an error if anything is found
|
||||||
|
// Assets.ETwo() - it's an error if two or more are found
|
||||||
|
//
|
||||||
|
// Errors can be stored in variables, or in string builders,
|
||||||
|
// or in json trees. This example tells it to put error
|
||||||
|
// messages into a string variable:
|
||||||
|
//
|
||||||
|
// FString ErrorMessage;
|
||||||
|
// Assets.Errors(ErrorMessage)
|
||||||
|
//
|
||||||
|
// Once the Assets object is configured, it's time to scan
|
||||||
|
// the assets. Use 'Info' if you just want to see
|
||||||
|
// FAssetData. Use 'Load' if you want to load the assets
|
||||||
|
// into memory. Both of these functions return true if
|
||||||
|
// there were no errors, or false if there was one.
|
||||||
|
//
|
||||||
|
// bool ok = Assets.Load();
|
||||||
|
//
|
||||||
|
// Once you've scanned the assets, you can examine the
|
||||||
|
// results using the following methods. The objects array
|
||||||
|
// will be empty if you called 'Info' instead of 'Load':
|
||||||
|
//
|
||||||
|
// const TArray<FAssetData>& AllData();
|
||||||
|
// const FAssetData& OneData();
|
||||||
|
// const TArray<UBlueprint*> Objects();
|
||||||
|
// UBlueprint* Object();
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// MCPAssets configuration methods can be chained:
|
||||||
|
//
|
||||||
|
// Assets.Limit(100).Errors(ErrMsg).ENone().ETwo();
|
||||||
|
//
|
||||||
|
////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
class MCPAssetsBase
|
class MCPAssetsBase
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
MCPAssetsBase(UClass* InTargetClass);
|
MCPAssetsBase& NoScans() { Scans.Empty(); return *this; }
|
||||||
MCPAssetsBase& Scan(UClass* Class) { Classes.Add(Class); return *this; }
|
MCPAssetsBase& Scan(UClass* Class) { Scans.Add(Class); return *this; }
|
||||||
template<class T> MCPAssetsBase& Scan() { return Scan(T::StaticClass()); }
|
template<class T> MCPAssetsBase& Scan() { return Scan(T::StaticClass()); }
|
||||||
MCPAssetsBase& Exact(const FString& InName);
|
MCPAssetsBase& Exact(const FString& InName);
|
||||||
MCPAssetsBase& Substring(const FString& InFilter);
|
MCPAssetsBase& Substring(const FString& InFilter);
|
||||||
@@ -36,15 +114,16 @@ public:
|
|||||||
const FAssetData& OneData() const { return AssetResults[0]; }
|
const FAssetData& OneData() const { return AssetResults[0]; }
|
||||||
|
|
||||||
private:
|
private:
|
||||||
bool Execute(bool bLoad);
|
FARFilter ConfigureFilter();
|
||||||
void ConfigureFilterClassPaths(FARFilter &Filter);
|
|
||||||
bool AssetMatches(const FAssetData &Data);
|
bool AssetMatches(const FAssetData &Data);
|
||||||
UObject *TryLoadAsset(const FAssetData &Asset);
|
UObject *TryLoadAsset(const FAssetData &Asset);
|
||||||
void SetError(const FString &Msg);
|
void SetError(const FString &Msg);
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
|
MCPAssetsBase(UClass* InTargetClass);
|
||||||
|
|
||||||
UClass* TargetClass;
|
UClass* TargetClass;
|
||||||
TArray<UClass*, TInlineAllocator<4>> Classes;
|
TSet<UClass*> Scans;
|
||||||
TArray<FAssetData> AssetResults;
|
TArray<FAssetData> AssetResults;
|
||||||
TArray<UObject*> UObjectResults;
|
TArray<UObject*> UObjectResults;
|
||||||
FString MatchName;
|
FString MatchName;
|
||||||
@@ -59,18 +138,6 @@ protected:
|
|||||||
MCPErrorCallback ErrorCB = MCPErrorCallback(nullptr);
|
MCPErrorCallback ErrorCB = MCPErrorCallback(nullptr);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// MCPAssets<T> — builder-pattern asset finder
|
|
||||||
//
|
|
||||||
// Usage:
|
|
||||||
// MCPAssets<UBlueprint> Assets;
|
|
||||||
// if (!Assets.Exact(Name).Errors(Result).ENone().ETwo().Load()) return;
|
|
||||||
// UBlueprint* BP = Assets.Object();
|
|
||||||
//
|
|
||||||
// MCPAssets<UMaterial> Materials;
|
|
||||||
// if (!Materials.Substring(Filter).Limit(100).Load()) return;
|
|
||||||
// for (UMaterial* Mat : Materials.Objects()) { ... }
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
template<class T>
|
template<class T>
|
||||||
class MCPAssets : public MCPAssetsBase
|
class MCPAssets : public MCPAssetsBase
|
||||||
@@ -82,5 +149,5 @@ public:
|
|||||||
{
|
{
|
||||||
return TArrayView<T* const>(reinterpret_cast<T* const*>(UObjectResults.GetData()), UObjectResults.Num());
|
return TArrayView<T* const>(reinterpret_cast<T* const*>(UObjectResults.GetData()), UObjectResults.Num());
|
||||||
}
|
}
|
||||||
T* Object() const { return static_cast<T*>(UObjectResults[0]); }
|
T* Object() const { return UObjectResults.IsEmpty() ? nullptr : static_cast<T*>(UObjectResults[0]); }
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
#include "CoreMinimal.h"
|
#include "CoreMinimal.h"
|
||||||
#include "EditorSubsystem.h"
|
#include "EditorSubsystem.h"
|
||||||
#include "Tickable.h"
|
#include "Tickable.h"
|
||||||
|
#include "UObject/ObjectSaveContext.h"
|
||||||
#include "MCPServer.h"
|
#include "MCPServer.h"
|
||||||
#include "MCPEditorSubsystem.generated.h"
|
#include "MCPEditorSubsystem.generated.h"
|
||||||
|
|
||||||
@@ -32,5 +33,7 @@ public:
|
|||||||
FBlueprintMCPServer* GetServer() const { return Server.Get(); }
|
FBlueprintMCPServer* GetServer() const { return Server.Get(); }
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
void OnAssetSaved(const FString& PackageFilename, UPackage* Package, FObjectPostSaveContext Context);
|
||||||
|
FDelegateHandle OnAssetSavedHandle;
|
||||||
TUniquePtr<FBlueprintMCPServer> Server;
|
TUniquePtr<FBlueprintMCPServer> Server;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -50,5 +50,9 @@ public:
|
|||||||
virtual FString GetDescription() const = 0;
|
virtual FString GetDescription() const = 0;
|
||||||
|
|
||||||
// Called after parameter fields have been populated from JSON.
|
// Called after parameter fields have been populated from JSON.
|
||||||
virtual void Handle(const FJsonObject* Json, FJsonObject* Result) = 0;
|
// Override the FStringBuilderBase version for plain-text responses, or the
|
||||||
|
// FJsonObject version for JSON responses. The dispatcher calls the text
|
||||||
|
// version first; if the builder remains empty, it falls back to JSON.
|
||||||
|
virtual void Handle(const FJsonObject* Json, FStringBuilderBase& Result) {}
|
||||||
|
virtual void Handle(const FJsonObject* Json, FJsonObject* Result) {}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -53,8 +53,8 @@ private:
|
|||||||
TMap<FString, UClass*> MCPHandlerRegistry; // tool name -> UMCPHandler subclass
|
TMap<FString, UClass*> MCPHandlerRegistry; // tool name -> UMCPHandler subclass
|
||||||
void BuildMCPHandlerRegistry();
|
void BuildMCPHandlerRegistry();
|
||||||
|
|
||||||
// Dispatch a tool call to the appropriate handler
|
// Dispatch a tool call to the appropriate handler, returning the response string.
|
||||||
void DispatchToolCall(const FString& ToolName, const FJsonObject* Params, FJsonObject* Result);
|
FString DispatchToolCall(const FString& ToolName, const FJsonObject* Params);
|
||||||
|
|
||||||
// Handle a complete JSON line and return the response JSON
|
// Handle a complete JSON line and return the response JSON
|
||||||
FString HandleRequest(const FString& Line);
|
FString HandleRequest(const FString& Line);
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ struct MCPErrorCallback
|
|||||||
MCPErrorCallback(std::nullptr_t);
|
MCPErrorCallback(std::nullptr_t);
|
||||||
MCPErrorCallback(FString& OutError);
|
MCPErrorCallback(FString& OutError);
|
||||||
MCPErrorCallback(FJsonObject* Result);
|
MCPErrorCallback(FJsonObject* Result);
|
||||||
|
MCPErrorCallback(FStringBuilderBase& OutResult);
|
||||||
|
|
||||||
void SetError(const FString& Msg) const { Func(Msg); }
|
void SetError(const FString& Msg) const { Func(Msg); }
|
||||||
};
|
};
|
||||||
@@ -111,6 +112,12 @@ public:
|
|||||||
|
|
||||||
// ----- Enum helpers -----
|
// ----- Enum helpers -----
|
||||||
// Convert enum value to string. If Prefix is specified, strip "Prefix_" from the front.
|
// Convert enum value to string. If Prefix is specified, strip "Prefix_" from the front.
|
||||||
|
template<typename T>
|
||||||
|
static FString EnumToString(TEnumAsByte<T> Value, const FString& Prefix = FString())
|
||||||
|
{
|
||||||
|
return EnumToString<T>((T)Value, Prefix);
|
||||||
|
}
|
||||||
|
|
||||||
template<typename T>
|
template<typename T>
|
||||||
static FString EnumToString(T Value, const FString& Prefix = FString())
|
static FString EnumToString(T Value, const FString& Prefix = FString())
|
||||||
{
|
{
|
||||||
@@ -192,6 +199,10 @@ public:
|
|||||||
static bool PopulateFromJson(UStruct* StructType, void* Container, const TSharedPtr<FJsonValue>& JsonValue, MCPErrorCallback Error);
|
static bool PopulateFromJson(UStruct* StructType, void* Container, const TSharedPtr<FJsonValue>& JsonValue, MCPErrorCallback Error);
|
||||||
static bool PopulateFromJson(UStruct* StructType, void* Container, const FJsonObject* Json, MCPErrorCallback Error);
|
static bool PopulateFromJson(UStruct* StructType, void* Container, const FJsonObject* Json, MCPErrorCallback Error);
|
||||||
|
|
||||||
|
// ----- Command help -----
|
||||||
|
static FString FormatPropertyType(FProperty* Prop);
|
||||||
|
static void FormatCommandHelp(UClass* HandlerClass, FStringBuilderBase& Result);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
static FString SetPropertyFromJson(void* Container, FProperty* Prop, const FString& FieldName, const FJsonObject* Json);
|
static FString SetPropertyFromJson(void* Container, FProperty* Prop, const FString& FieldName, const FJsonObject* Json);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,79 +7,14 @@
|
|||||||
#include "Misc/Paths.h"
|
#include "Misc/Paths.h"
|
||||||
#include "ShaderCore.h"
|
#include "ShaderCore.h"
|
||||||
|
|
||||||
#if WITH_EDITOR
|
|
||||||
#include "Engine/Blueprint.h"
|
|
||||||
#include "UObject/SavePackage.h"
|
|
||||||
#include "UObject/ObjectSaveContext.h"
|
|
||||||
#include "EdGraph/EdGraph.h"
|
|
||||||
#include "BlueprintExporter.h"
|
|
||||||
#include "Misc/FileHelper.h"
|
|
||||||
#include "Exporters/Exporter.h"
|
|
||||||
#include "UnrealExporter.h"
|
|
||||||
#endif
|
|
||||||
|
|
||||||
IMPLEMENT_PRIMARY_GAME_MODULE(FlxIntegrationModuleImpl, Integration, "Integration");
|
IMPLEMENT_PRIMARY_GAME_MODULE(FlxIntegrationModuleImpl, Integration, "Integration");
|
||||||
|
|
||||||
void FlxIntegrationModuleImpl::StartupModule()
|
void FlxIntegrationModuleImpl::StartupModule()
|
||||||
{
|
{
|
||||||
FString ShaderDir = FPaths::Combine(FPaths::ProjectDir(), TEXT("Shaders"));
|
FString ShaderDir = FPaths::Combine(FPaths::ProjectDir(), TEXT("Shaders"));
|
||||||
AddShaderSourceDirectoryMapping(TEXT("/Project/Integration"), ShaderDir);
|
AddShaderSourceDirectoryMapping(TEXT("/Project/Integration"), ShaderDir);
|
||||||
|
|
||||||
#if WITH_EDITOR
|
|
||||||
OnAssetSavedHandle = UPackage::PackageSavedWithContextEvent.AddRaw(
|
|
||||||
this, &FlxIntegrationModuleImpl::OnAssetSaved);
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void FlxIntegrationModuleImpl::ShutdownModule()
|
void FlxIntegrationModuleImpl::ShutdownModule()
|
||||||
{
|
{
|
||||||
#if WITH_EDITOR
|
|
||||||
UPackage::PackageSavedWithContextEvent.Remove(OnAssetSavedHandle);
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#if WITH_EDITOR
|
|
||||||
void FlxIntegrationModuleImpl::OnAssetSaved(const FString& PackageFilename, UPackage* Package, FObjectPostSaveContext Context)
|
|
||||||
{
|
|
||||||
if (!Package) return;
|
|
||||||
|
|
||||||
FString PkDir = FPaths::ProjectDir() / TEXT("Saved") / TEXT("BlueprintExports") / FPaths::GetBaseFilename(PackageFilename);
|
|
||||||
IFileManager::Get().DeleteDirectory(*PkDir, false, true);
|
|
||||||
|
|
||||||
// Export the whole package in both formats for comparison.
|
|
||||||
{
|
|
||||||
FStringOutputDevice Archive;
|
|
||||||
const FExportObjectInnerContext InnerContext;
|
|
||||||
UExporter::ExportToOutputDevice(&InnerContext, Package, nullptr, Archive, TEXT("copy"), 0);
|
|
||||||
FFileHelper::SaveStringToFile(Archive, *(PkDir / TEXT("COPY_DUMP.txt")));
|
|
||||||
}
|
|
||||||
{
|
|
||||||
FStringOutputDevice Archive;
|
|
||||||
const FExportObjectInnerContext InnerContext;
|
|
||||||
UExporter::ExportToOutputDevice(&InnerContext, Package, nullptr, Archive, TEXT("t3d"), 0);
|
|
||||||
FFileHelper::SaveStringToFile(Archive, *(PkDir / TEXT("T3D_DUMP.txt")));
|
|
||||||
}
|
|
||||||
|
|
||||||
TArray<UObject*> AllObjects;
|
|
||||||
GetObjectsWithPackage(Package, AllObjects);
|
|
||||||
for (UObject *Obj : AllObjects)
|
|
||||||
{
|
|
||||||
if (UBlueprint *BP = Cast<UBlueprint>(Obj))
|
|
||||||
{
|
|
||||||
FString BPDir = PkDir / BP->GetName();
|
|
||||||
TArray<UEdGraph*> AllGraphs;
|
|
||||||
BP->GetAllGraphs(AllGraphs);
|
|
||||||
for (UEdGraph* Graph : AllGraphs)
|
|
||||||
{
|
|
||||||
FlxBlueprintExporter Exporter(Graph);
|
|
||||||
|
|
||||||
FString FilePath = BPDir / Graph->GetName() + TEXT(".txt");
|
|
||||||
FString DetailsPath = BPDir / TEXT("DETAILS") / Graph->GetName() + TEXT(".txt");
|
|
||||||
FFileHelper::SaveStringToFile(Exporter.GetOutput(), *FilePath);
|
|
||||||
FFileHelper::SaveStringToFile(Exporter.GetDetails(), *DetailsPath);
|
|
||||||
UE_LOG(LogLuprexIntegration, Warning, TEXT("Blueprint export: %s"), *FilePath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|||||||
@@ -4,17 +4,10 @@
|
|||||||
|
|
||||||
#include "CoreMinimal.h"
|
#include "CoreMinimal.h"
|
||||||
#include "Modules/ModuleInterface.h"
|
#include "Modules/ModuleInterface.h"
|
||||||
#include "UObject/ObjectSaveContext.h"
|
|
||||||
|
|
||||||
class FlxIntegrationModuleImpl : public IModuleInterface
|
class FlxIntegrationModuleImpl : public IModuleInterface
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
virtual void StartupModule() override;
|
virtual void StartupModule() override;
|
||||||
virtual void ShutdownModule() override;
|
virtual void ShutdownModule() override;
|
||||||
|
|
||||||
private:
|
|
||||||
#if WITH_EDITOR
|
|
||||||
void OnAssetSaved(const FString& PackageFilename, UPackage* Package, FObjectPostSaveContext Context);
|
|
||||||
FDelegateHandle OnAssetSavedHandle;
|
|
||||||
#endif
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,124 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Inlines method bodies from a .cpp file into the corresponding .h file.
|
|
||||||
|
|
||||||
Scans the .cpp for method definitions matching "ClassName::MethodName(...)"
|
|
||||||
and extracts the body (from opening '{' to closing '}'). Then reads the .h
|
|
||||||
file and replaces matching declaration-only lines (ending with ';') with the
|
|
||||||
declaration (minus ';') followed by the body.
|
|
||||||
|
|
||||||
Usage: python3 tools/inline-methods.py <header.h> <source.cpp>
|
|
||||||
|
|
||||||
Outputs the new header to stdout.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sys
|
|
||||||
import re
|
|
||||||
|
|
||||||
|
|
||||||
def extract_bodies(cpp_lines):
|
|
||||||
"""Extract method bodies from cpp file.
|
|
||||||
Returns dict of (ClassName, MethodName) -> list of body lines (from '{' to '}')."""
|
|
||||||
bodies = {}
|
|
||||||
i = 0
|
|
||||||
while i < len(cpp_lines):
|
|
||||||
# Match lines like: void UMCPHandler_Foo::Handle(...)
|
|
||||||
m = re.match(r'^\S.*?\s+(\w+)::(\w+)\s*\(', cpp_lines[i])
|
|
||||||
if not m:
|
|
||||||
i += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
class_name = m.group(1)
|
|
||||||
method_name = m.group(2)
|
|
||||||
|
|
||||||
# Skip forward to the line containing '{'
|
|
||||||
while i < len(cpp_lines) and '{' not in cpp_lines[i]:
|
|
||||||
i += 1
|
|
||||||
if i >= len(cpp_lines):
|
|
||||||
break
|
|
||||||
|
|
||||||
# Collect from '{' to matching '}'
|
|
||||||
brace_depth = 0
|
|
||||||
body_lines = []
|
|
||||||
while i < len(cpp_lines):
|
|
||||||
line = cpp_lines[i]
|
|
||||||
brace_depth += line.count('{') - line.count('}')
|
|
||||||
body_lines.append(line)
|
|
||||||
i += 1
|
|
||||||
if brace_depth == 0:
|
|
||||||
break
|
|
||||||
|
|
||||||
bodies[(class_name, method_name)] = body_lines
|
|
||||||
|
|
||||||
return bodies
|
|
||||||
|
|
||||||
|
|
||||||
def inline_into_header(h_lines, bodies):
|
|
||||||
"""Replace declaration-only methods in header with inlined bodies."""
|
|
||||||
output = []
|
|
||||||
for line in h_lines:
|
|
||||||
# Match declaration lines like:
|
|
||||||
# \tvirtual void Handle(const FJsonObject* Json, ...) override;
|
|
||||||
# Capture: indent, everything before method name, method name, rest up to ';'
|
|
||||||
m = re.match(r'^(\t+)(.*?\s+)(\w+)\s*(\(.*\)\s*(?:const\s*)?(?:override\s*)?);', line)
|
|
||||||
if m:
|
|
||||||
indent = m.group(1)
|
|
||||||
prefix = m.group(2) # e.g. "virtual void "
|
|
||||||
method_name = m.group(3)
|
|
||||||
params = m.group(4) # e.g. "(const FJsonObject* Json, FJsonObject* Result) override"
|
|
||||||
|
|
||||||
# Find which class we're inside
|
|
||||||
class_name = None
|
|
||||||
for prev in reversed(output):
|
|
||||||
cm = re.match(r'^class\s+(\w+)\s*', prev)
|
|
||||||
if cm:
|
|
||||||
class_name = cm.group(1)
|
|
||||||
break
|
|
||||||
|
|
||||||
if class_name and (class_name, method_name) in bodies:
|
|
||||||
body_lines = bodies[(class_name, method_name)]
|
|
||||||
# Emit the declaration as a definition (replace ';' with body)
|
|
||||||
output.append(f'{indent}{prefix}{method_name}{params}')
|
|
||||||
for bline in body_lines:
|
|
||||||
if bline.strip():
|
|
||||||
output.append(indent + bline)
|
|
||||||
else:
|
|
||||||
output.append('')
|
|
||||||
continue
|
|
||||||
|
|
||||||
output.append(line)
|
|
||||||
return output
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
if len(sys.argv) != 3:
|
|
||||||
print(f"Usage: {sys.argv[0]} <header.h> <source.cpp>", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
h_path = sys.argv[1]
|
|
||||||
cpp_path = sys.argv[2]
|
|
||||||
|
|
||||||
with open(cpp_path) as f:
|
|
||||||
cpp_lines = [line.rstrip('\n') for line in f]
|
|
||||||
|
|
||||||
with open(h_path) as f:
|
|
||||||
h_lines = [line.rstrip('\n') for line in f]
|
|
||||||
|
|
||||||
bodies = extract_bodies(cpp_lines)
|
|
||||||
|
|
||||||
if not bodies:
|
|
||||||
print("No method bodies found in cpp file.", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
print(f"Found {len(bodies)} method(s) to inline:", file=sys.stderr)
|
|
||||||
for (cls, method) in bodies:
|
|
||||||
print(f" {cls}::{method}", file=sys.stderr)
|
|
||||||
|
|
||||||
result = inline_into_header(h_lines, bodies)
|
|
||||||
|
|
||||||
for line in result:
|
|
||||||
print(line)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
main()
|
|
||||||
@@ -68,7 +68,7 @@ def disconnect():
|
|||||||
|
|
||||||
|
|
||||||
def send_and_receive(message):
|
def send_and_receive(message):
|
||||||
"""Send a JSON message to the editor and return the response."""
|
"""Send a JSON message to the editor and return the null-terminated response."""
|
||||||
data = json.dumps(message) + "\n"
|
data = json.dumps(message) + "\n"
|
||||||
sock.sendall(data.encode())
|
sock.sendall(data.encode())
|
||||||
|
|
||||||
@@ -78,10 +78,10 @@ def send_and_receive(message):
|
|||||||
if not chunk:
|
if not chunk:
|
||||||
raise ConnectionError("Connection closed")
|
raise ConnectionError("Connection closed")
|
||||||
result += chunk
|
result += chunk
|
||||||
try:
|
if b"\0" in result:
|
||||||
return json.loads(result)
|
break
|
||||||
except json.JSONDecodeError:
|
|
||||||
continue
|
return result[:result.index(b"\0")].decode()
|
||||||
|
|
||||||
|
|
||||||
def forward_to_editor(arguments):
|
def forward_to_editor(arguments):
|
||||||
@@ -128,10 +128,8 @@ def handle_message(msg):
|
|||||||
params = msg.get("params", {})
|
params = msg.get("params", {})
|
||||||
arguments = params.get("arguments", {})
|
arguments = params.get("arguments", {})
|
||||||
result = forward_to_editor(arguments)
|
result = forward_to_editor(arguments)
|
||||||
is_error = "error" in result
|
|
||||||
return make_jsonrpc(msg_id, {
|
return make_jsonrpc(msg_id, {
|
||||||
"content": [{"type": "text", "text": json.dumps(result)}],
|
"content": [{"type": "text", "text": result}],
|
||||||
**({"isError": True} if is_error else {}),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
66
tools/mcp-test.py
Executable file
66
tools/mcp-test.py
Executable file
@@ -0,0 +1,66 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Human-friendly MCP test client.
|
||||||
|
|
||||||
|
Usage: python3 tools/mcp-test.py command=show_commands
|
||||||
|
python3 tools/mcp-test.py command=list_blueprint_assets filter=lx
|
||||||
|
python3 tools/mcp-test.py command=show_commands verbose=true
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import socket
|
||||||
|
|
||||||
|
HOST = "localhost"
|
||||||
|
PORT = 9847
|
||||||
|
TIMEOUT = 120
|
||||||
|
|
||||||
|
def main():
|
||||||
|
msg = {}
|
||||||
|
for arg in sys.argv[1:]:
|
||||||
|
key, _, value = arg.partition("=")
|
||||||
|
if value.lower() == "true":
|
||||||
|
value = True
|
||||||
|
elif value.lower() == "false":
|
||||||
|
value = False
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
value = int(value)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
msg[key] = value
|
||||||
|
|
||||||
|
if not msg:
|
||||||
|
print("Usage: python3 tools/mcp-test.py command=show_commands")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
sock.settimeout(TIMEOUT)
|
||||||
|
try:
|
||||||
|
sock.connect((HOST, PORT))
|
||||||
|
except (ConnectionRefusedError, socket.timeout, OSError) as e:
|
||||||
|
print(f"Cannot connect to {HOST}:{PORT} — is the editor running?")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
sock.sendall((json.dumps(msg) + "\n").encode())
|
||||||
|
|
||||||
|
result = b""
|
||||||
|
while True:
|
||||||
|
chunk = sock.recv(65536)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
result += chunk
|
||||||
|
if b"\0" in result:
|
||||||
|
break
|
||||||
|
|
||||||
|
sock.close()
|
||||||
|
result = result[:result.index(b"\0")].decode() if b"\0" in result else result.decode()
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed = json.loads(result)
|
||||||
|
print(json.dumps(parsed, indent=2))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
print(result)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user