From 695de53b30110c0797b526ffae2bda359006282d Mon Sep 17 00:00:00 2001 From: jyelon Date: Sun, 8 Mar 2026 21:28:47 -0400 Subject: [PATCH] More MCP work --- CLAUDE.md | 1 - .../Private}/BlueprintExporter.cpp | 7 +- .../BlueprintMCP/Private/MCPAssetFinder.cpp | 18 +- .../Private/MCPEditorSubsystem.cpp | 55 ++++++ .../Private/MCPHandlers_Discovery.h | 95 +++------- .../BlueprintMCP/Private/MCPHandlers_Read.h | 103 +++------- .../Source/BlueprintMCP/Private/MCPServer.cpp | 177 +++--------------- .../Source/BlueprintMCP/Private/MCPUtils.cpp | 65 +++++++ .../BlueprintMCP/Public}/BlueprintExporter.h | 4 - .../BlueprintMCP/Public/MCPAssetFinder.h | 109 ++++++++--- .../BlueprintMCP/Public/MCPEditorSubsystem.h | 3 + .../Source/BlueprintMCP/Public/MCPHandler.h | 6 +- .../Source/BlueprintMCP/Public/MCPServer.h | 4 +- .../Source/BlueprintMCP/Public/MCPUtils.h | 11 ++ Source/Integration/Integration.cpp | 65 ------- Source/Integration/Integration.h | 7 - tools/inline-methods.py | 124 ------------ tools/mcp-bridge.py | 14 +- tools/mcp-test.py | 66 +++++++ 19 files changed, 388 insertions(+), 546 deletions(-) rename {Source/Integration => Plugins/BlueprintMCP/Source/BlueprintMCP/Private}/BlueprintExporter.cpp (98%) rename {Source/Integration => Plugins/BlueprintMCP/Source/BlueprintMCP/Public}/BlueprintExporter.h (99%) delete mode 100644 tools/inline-methods.py create mode 100755 tools/mcp-test.py diff --git a/CLAUDE.md b/CLAUDE.md index 40ebe8f0..e90ded11 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 c++` — lightweight rebuild (only if you've only edited C++ files in this repo) - 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 diff --git a/Source/Integration/BlueprintExporter.cpp b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/BlueprintExporter.cpp similarity index 98% rename from Source/Integration/BlueprintExporter.cpp rename to Plugins/BlueprintMCP/Source/BlueprintMCP/Private/BlueprintExporter.cpp index d4be33a3..4abd7d6b 100644 --- a/Source/Integration/BlueprintExporter.cpp +++ b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/BlueprintExporter.cpp @@ -1,7 +1,4 @@ -#if WITH_EDITOR - #include "BlueprintExporter.h" -#include "Common.h" #include "Engine/Blueprint.h" #include "EdGraph/EdGraph.h" #include "EdGraph/EdGraphNode.h" @@ -166,7 +163,7 @@ FString FlxBlueprintExporter::FormatPinName(UEdGraphPin *Pin) return SanitizedRaw; // 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()); return FString::Printf(TEXT("?%s"), *SanitizedRaw); } @@ -393,5 +390,3 @@ void FlxBlueprintExporter::EmitNodeList() *FormatNodeName(Node), *Node->NodeGuid.ToString()); } } - -#endif diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPAssetFinder.cpp b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPAssetFinder.cpp index 349d0f43..26a970e8 100644 --- a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPAssetFinder.cpp +++ b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPAssetFinder.cpp @@ -14,6 +14,7 @@ MCPAssetsBase::MCPAssetsBase(UClass* InTargetClass) : TargetClass(InTargetClass) { + Scans.Add(InTargetClass); } MCPAssetsBase& MCPAssetsBase::Exact(const FString& InName) @@ -62,10 +63,8 @@ bool MCPAssetsBase::Info() IAssetRegistry& AR = FModuleManager::LoadModuleChecked("AssetRegistry").Get(); while (AR.IsLoadingAssets()) FPlatformProcess::Sleep(0.1f); - FARFilter Filter; TArray Candidates; - ConfigureFilterClassPaths(Filter); - AR.GetAssets(Filter, Candidates); + AR.GetAssets(ConfigureFilter(), Candidates); for (const FAssetData &Data : Candidates) { if (AssetMatches(Data)) AssetResults.Add(Data); @@ -117,22 +116,17 @@ bool MCPAssetsBase::Load() return true; } -void MCPAssetsBase::ConfigureFilterClassPaths(FARFilter &Filter) +FARFilter MCPAssetsBase::ConfigureFilter() { - if (Classes.IsEmpty()) - { - Filter.ClassPaths.Add(TargetClass->GetClassPathName()); - } - else - { - for (UClass* C : Classes) Filter.ClassPaths.Add(C->GetClassPathName()); - } + FARFilter Filter; + for (UClass* C : Scans) Filter.ClassPaths.Add(C->GetClassPathName()); Filter.bRecursiveClasses = !bNoDerived; if (!bAllContent) { Filter.PackagePaths.Add(FName(TEXT("/Game"))); Filter.bRecursivePaths = true; } + return Filter; } bool MCPAssetsBase::AssetMatches(const FAssetData &Asset) diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPEditorSubsystem.cpp b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPEditorSubsystem.cpp index 370d17de..e6a1bfc3 100644 --- a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPEditorSubsystem.cpp +++ b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPEditorSubsystem.cpp @@ -1,5 +1,11 @@ #include "MCPEditorSubsystem.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) { @@ -11,6 +17,9 @@ void UBlueprintMCPEditorSubsystem::Initialize(FSubsystemCollectionBase& Collecti return; } + OnAssetSavedHandle = UPackage::PackageSavedWithContextEvent.AddUObject( + this, &UBlueprintMCPEditorSubsystem::OnAssetSaved); + Server = MakeUnique(); if (Server->Start(9847, /*bEditorMode=*/true)) { @@ -25,6 +34,8 @@ void UBlueprintMCPEditorSubsystem::Initialize(FSubsystemCollectionBase& Collecti void UBlueprintMCPEditorSubsystem::Deinitialize() { + UPackage::PackageSavedWithContextEvent.Remove(OnAssetSavedHandle); + if (Server) { Server->Stop(); @@ -52,3 +63,47 @@ TStatId UBlueprintMCPEditorSubsystem::GetStatId() const { 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 AllObjects; + GetObjectsWithPackage(Package, AllObjects); + for (UObject *Obj : AllObjects) + { + if (UBlueprint *BP = Cast(Obj)) + { + FString BPDir = PkDir / BP->GetName(); + TArray 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); + } + } + } +} diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Discovery.h b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Discovery.h index bb576c84..15235732 100644 --- a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Discovery.h +++ b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Discovery.h @@ -597,9 +597,9 @@ public: 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> CollectHandlers() const { - // Collect all handler classes sorted by tool name TArray> Handlers; for (TObjectIterator It; It; ++It) { @@ -612,84 +612,35 @@ public: Handlers.Add({ToolName, Class}); } Handlers.Sort(); + return Handlers; + } - if (!Verbose) - { - // Compact format: "command_name(param1,param2,?optional)" - TArray> Lines; - for (const auto& Pair : Handlers) - { - FString Line = Pair.Key + TEXT("("); - bool bFirst = true; - for (TFieldIterator 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(Line)); - } - Result->SetNumberField(TEXT("count"), Lines.Num()); - Result->SetArrayField(TEXT("commands"), Lines); - return; - } + virtual void Handle(const FJsonObject* Json, FStringBuilderBase& Result) override + { + auto Handlers = CollectHandlers(); + Result.Appendf(TEXT("%d commands:\n"), Handlers.Num()); - TArray> CommandsArray; for (const auto& Pair : Handlers) { + if (Verbose) + { + MCPUtils::FormatCommandHelp(Pair.Value, Result); + continue; + } + + // Non-verbose: just the signature line UClass* Class = Pair.Value; - const IMCPHandler* Handler = Cast(Class->GetDefaultObject()); - - TSharedRef Entry = MakeShared(); - Entry->SetStringField(TEXT("command"), Pair.Key); - Entry->SetStringField(TEXT("description"), Handler->GetDescription()); - - // Document parameters from UPROPERTY fields - TArray> ParamsArray; + Result.Append(Pair.Key); + Result.Append(TEXT("(")); + bool bFirst = true; for (TFieldIterator PropIt(Class, EFieldIterationFlags::None); PropIt; ++PropIt) { - FProperty* Prop = *PropIt; - TSharedRef ParamObj = MakeShared(); - ParamObj->SetStringField(TEXT("name"), MCPUtils::PropertyNameToJsonKey(Prop->GetName())); - ParamObj->SetBoolField(TEXT("required"), !Prop->HasMetaData(TEXT("Optional"))); - - // Type - if (CastField(Prop)) - ParamObj->SetStringField(TEXT("type"), TEXT("string")); - else if (CastField(Prop)) - ParamObj->SetStringField(TEXT("type"), TEXT("integer")); - else if (CastField(Prop) || CastField(Prop)) - ParamObj->SetStringField(TEXT("type"), TEXT("number")); - else if (CastField(Prop)) - ParamObj->SetStringField(TEXT("type"), TEXT("boolean")); - else if (FStructProperty* SP = CastField(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(ParamObj)); + if (!bFirst) Result.Append(TEXT(",")); + bFirst = false; + if (PropIt->HasMetaData(TEXT("Optional"))) Result.Append(TEXT("?")); + Result.Append(MCPUtils::PropertyNameToJsonKey(PropIt->GetName())); } - if (ParamsArray.Num() > 0) - { - Entry->SetArrayField(TEXT("parameters"), ParamsArray); - } - - CommandsArray.Add(MakeShared(Entry)); + Result.Append(TEXT(")\n")); } - Result->SetNumberField(TEXT("count"), CommandsArray.Num()); - Result->SetArrayField(TEXT("commands"), CommandsArray); } }; diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Read.h b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Read.h index 226a8252..8644781e 100644 --- a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Read.h +++ b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPHandlers_Read.h @@ -38,101 +38,54 @@ public: UPROPERTY(meta=(Optional, Description="Filter by parent class name (substring match)")) FString ParentClass; - UPROPERTY(meta=(Optional, Description="Type filter: 'all' (default), 'regular', or 'level'")) - FString Type; + UPROPERTY(meta=(Optional, Description="Include regular blueprints (default true)")) + bool IncludeRegular = true; + + UPROPERTY(meta=(Optional, Description="Include regular blueprints (default true)")) + bool IncludeLevel = true; virtual FString GetDescription() const override { return TEXT("List all Blueprint assets in the project, with optional filtering by name, parent class, or type."); } - virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override + virtual void Handle(const FJsonObject* Json, FStringBuilderBase& Result) override { - // type: "all" (default), "regular", "level" - bool bIncludeRegular = Type.IsEmpty() || Type == TEXT("all") || Type == TEXT("regular"); - bool bIncludeLevel = Type.IsEmpty() || Type == TEXT("all") || Type == TEXT("level"); - - MCPAssets AllBlueprints; - AllBlueprints.Info(); - MCPAssets AllWorlds; - AllWorlds.Info(); - - TArray> Entries; - if (bIncludeRegular) - for (const FAssetData& Asset : AllBlueprints.AllData()) + MCPAssets Assets; + Assets.NoScans().Substring(Filter).Limit(500); + if (IncludeRegular) Assets.Scan(); + if (IncludeLevel) Assets.Scan(); + Assets.Info(); + for (const FAssetData& Asset : Assets.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; - } - } - + // Extract parent class name FString ParentClassName; - Asset.GetTagValue(FName(TEXT("ParentClass")), ParentClassName); - // Tag stores full path — extract short name - int32 DotIndex; - if (ParentClassName.FindLastChar('.', DotIndex)) + if (Asset.AssetClassPath == UWorld::StaticClass()->GetClassPathName()) { - 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 (!ParentClassName.Contains(ParentClass, ESearchCase::IgnoreCase)) + if (!ParentClassName.Equals(ParentClass, ESearchCase::IgnoreCase)) { continue; } } - TSharedRef Entry = MakeShared(); - Entry->SetStringField(TEXT("name"), Name); - Entry->SetStringField(TEXT("path"), Path); - Entry->SetStringField(TEXT("parentClass"), ParentClassName); - Entries.Add(MakeShared(Entry)); + Result.Appendf(TEXT("%30s %s\n"), *ParentClassName, *Asset.PackageName.ToString()); } - - // 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 Entry = MakeShared(); - Entry->SetStringField(TEXT("name"), Name); - Entry->SetStringField(TEXT("path"), Path); - Entry->SetStringField(TEXT("parentClass"), TEXT("LevelScriptActor")); - Entry->SetBoolField(TEXT("isLevelBlueprint"), true); - Entries.Add(MakeShared(Entry)); - } - - Result->SetNumberField(TEXT("count"), Entries.Num()); - Result->SetNumberField(TEXT("total"), AllBlueprints.AllData().Num() + AllWorlds.AllData().Num()); - Result->SetArrayField(TEXT("blueprints"), Entries); } }; diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPServer.cpp b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPServer.cpp index 1aa5ee60..aacf1c52 100644 --- a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPServer.cpp +++ b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPServer.cpp @@ -112,158 +112,42 @@ FMCPServer* FMCPServer::Get() return Sub ? Sub->GetServer() : nullptr; } - -// ============================================================ -// 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) +FString FMCPServer::DispatchToolCall(const FString& ToolName, const FJsonObject* Params) { - FKismetEditorUtilities::CompileBlueprint(BP, Opts, nullptr); -} - -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 + UClass** HandlerClass = MCPHandlerRegistry.Find(ToolName); + if (!HandlerClass) { - CompileBlueprintInner(BP, Opts); - return 0; + return FString::Printf(TEXT("Unknown tool: %s"), *ToolName); } - __except (1) + + TStrongObjectPtr HandlerObj(NewObject(GetTransientPackage(), *HandlerClass)); + IMCPHandler* Handler = Cast(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( - UPackage* Package, UObject* Asset, const TCHAR* Filename, - FSavePackageArgs* SaveArgs, ESavePackageResult* OutResult) -{ - __try + // Try text handler first; fall back to JSON if nothing was written. + TStringBuilder<32768> TextResult; + Handler->Handle(Params, TextResult); + if (TextResult.Len() > 0) { - *OutResult = SavePackageInner(Package, Asset, Filename, SaveArgs); - return 0; - } - __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(Owner, ExprClass); - if (!*OutExpr) return; - - (*OutExpr)->MaterialExpressionEditorX = PosX; - (*OutExpr)->MaterialExpressionEditorY = PosY; - - if (Material) - { - Material->GetExpressionCollection().AddExpression(*OutExpr); - if (Material->MaterialGraph) + FString Result = TextResult.ToString(); + for (int32 i = 0; i < Result.Len(); ++i) { - Material->MaterialGraph->RebuildGraph(); + if (Result[i] == TEXT('\0')) Result[i] = TEXT(' '); } - Material->PreEditChange(nullptr); - Material->PostEditChange(); - Material->MarkPackageDirty(); + return Result; } - 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 -static void CleanupBadExpressionInner(UMaterial* Material, UMaterialFunction* MatFunc, UMaterialExpression* BadExpr) -{ - if (!BadExpr) return; - 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 HandlerObj(NewObject(GetTransientPackage(), *HandlerClass)); - IMCPHandler* Handler = Cast(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)); - } + // Invoke the Json handler. + TSharedRef JsonResult = MakeShared(); + Handler->Handle(Params, &*JsonResult); + return MCPUtils::JsonToString(JsonResult); } // ============================================================ @@ -289,9 +173,7 @@ FString FMCPServer::HandleRequest(const FString& Line) } Request->RemoveField(TEXT("command")); - TSharedRef Result = MakeShared(); - DispatchToolCall(Command, Request.Get(), &*Result); - return MCPUtils::JsonToString(Result); + return DispatchToolCall(Command, Request.Get()); } // ============================================================ @@ -482,11 +364,10 @@ void FMCPServer::ClientThreadFunc(FMCPServer* Server, TSharedPtrSend((const uint8*)Utf8.Get(), Utf8.Length(), BytesSent); + Socket->Send((const uint8*)Utf8.Get(), Utf8.Length() + 1, BytesSent); } } diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPUtils.cpp b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPUtils.cpp index 1345176d..c6ffedfb 100644 --- a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPUtils.cpp +++ b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPUtils.cpp @@ -94,6 +94,10 @@ MCPErrorCallback::MCPErrorCallback(FJsonObject* Result) : 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 // ============================================================ @@ -1494,3 +1498,64 @@ bool MCPUtils::PopulateFromJson( return true; } + +// ============================================================ +// FormatPropertyType — human-readable type name for a UPROPERTY +// ============================================================ + +FString MCPUtils::FormatPropertyType(FProperty* Prop) +{ + if (CastField(Prop)) return TEXT("string"); + if (CastField(Prop)) return TEXT("integer"); + if (CastField(Prop) || CastField(Prop)) return TEXT("number"); + if (CastField(Prop)) return TEXT("boolean"); + if (FStructProperty* SP = CastField(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(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 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 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")); + } +} diff --git a/Source/Integration/BlueprintExporter.h b/Plugins/BlueprintMCP/Source/BlueprintMCP/Public/BlueprintExporter.h similarity index 99% rename from Source/Integration/BlueprintExporter.h rename to Plugins/BlueprintMCP/Source/BlueprintMCP/Public/BlueprintExporter.h index 1baa9697..ac31de3b 100644 --- a/Source/Integration/BlueprintExporter.h +++ b/Plugins/BlueprintMCP/Source/BlueprintMCP/Public/BlueprintExporter.h @@ -1,7 +1,5 @@ #pragma once -#if WITH_EDITOR - #include "CoreMinimal.h" #include "Engine/Blueprint.h" #include "EdGraph/EdGraph.h" @@ -124,5 +122,3 @@ private: TStringBuilder<4096> Output; TStringBuilder<4096> Details; }; - -#endif diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Public/MCPAssetFinder.h b/Plugins/BlueprintMCP/Source/BlueprintMCP/Public/MCPAssetFinder.h index 1f8dea01..f0f4aecd 100644 --- a/Plugins/BlueprintMCP/Source/BlueprintMCP/Public/MCPAssetFinder.h +++ b/Plugins/BlueprintMCP/Source/BlueprintMCP/Public/MCPAssetFinder.h @@ -9,15 +9,93 @@ struct FARFilter; -// ============================================================ -// MCPAssetsBase — non-template base for MCPAssets -// ============================================================ +//////////////////////////////////////////////////////////// +// +// MCPAssets - search for assets. +// +// +// Construct an object of class MCPAssets like this: +// +// MCPAssets 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(); +// Assets.Scan(); +// +// 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& AllData(); +// const FAssetData& OneData(); +// const TArray Objects(); +// UBlueprint* Object(); +// +// +// MCPAssets configuration methods can be chained: +// +// Assets.Limit(100).Errors(ErrMsg).ENone().ETwo(); +// +//////////////////////////////////////////////////////////// class MCPAssetsBase { public: - MCPAssetsBase(UClass* InTargetClass); - MCPAssetsBase& Scan(UClass* Class) { Classes.Add(Class); return *this; } + MCPAssetsBase& NoScans() { Scans.Empty(); return *this; } + MCPAssetsBase& Scan(UClass* Class) { Scans.Add(Class); return *this; } template MCPAssetsBase& Scan() { return Scan(T::StaticClass()); } MCPAssetsBase& Exact(const FString& InName); MCPAssetsBase& Substring(const FString& InFilter); @@ -36,15 +114,16 @@ public: const FAssetData& OneData() const { return AssetResults[0]; } private: - bool Execute(bool bLoad); - void ConfigureFilterClassPaths(FARFilter &Filter); + FARFilter ConfigureFilter(); bool AssetMatches(const FAssetData &Data); UObject *TryLoadAsset(const FAssetData &Asset); void SetError(const FString &Msg); protected: + MCPAssetsBase(UClass* InTargetClass); + UClass* TargetClass; - TArray> Classes; + TSet Scans; TArray AssetResults; TArray UObjectResults; FString MatchName; @@ -59,18 +138,6 @@ protected: MCPErrorCallback ErrorCB = MCPErrorCallback(nullptr); }; -// ============================================================ -// MCPAssets — builder-pattern asset finder -// -// Usage: -// MCPAssets Assets; -// if (!Assets.Exact(Name).Errors(Result).ENone().ETwo().Load()) return; -// UBlueprint* BP = Assets.Object(); -// -// MCPAssets Materials; -// if (!Materials.Substring(Filter).Limit(100).Load()) return; -// for (UMaterial* Mat : Materials.Objects()) { ... } -// ============================================================ template class MCPAssets : public MCPAssetsBase @@ -82,5 +149,5 @@ public: { return TArrayView(reinterpret_cast(UObjectResults.GetData()), UObjectResults.Num()); } - T* Object() const { return static_cast(UObjectResults[0]); } + T* Object() const { return UObjectResults.IsEmpty() ? nullptr : static_cast(UObjectResults[0]); } }; diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Public/MCPEditorSubsystem.h b/Plugins/BlueprintMCP/Source/BlueprintMCP/Public/MCPEditorSubsystem.h index b2e618b5..f43cc341 100644 --- a/Plugins/BlueprintMCP/Source/BlueprintMCP/Public/MCPEditorSubsystem.h +++ b/Plugins/BlueprintMCP/Source/BlueprintMCP/Public/MCPEditorSubsystem.h @@ -3,6 +3,7 @@ #include "CoreMinimal.h" #include "EditorSubsystem.h" #include "Tickable.h" +#include "UObject/ObjectSaveContext.h" #include "MCPServer.h" #include "MCPEditorSubsystem.generated.h" @@ -32,5 +33,7 @@ public: FBlueprintMCPServer* GetServer() const { return Server.Get(); } private: + void OnAssetSaved(const FString& PackageFilename, UPackage* Package, FObjectPostSaveContext Context); + FDelegateHandle OnAssetSavedHandle; TUniquePtr Server; }; diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Public/MCPHandler.h b/Plugins/BlueprintMCP/Source/BlueprintMCP/Public/MCPHandler.h index 3082d942..6460210a 100644 --- a/Plugins/BlueprintMCP/Source/BlueprintMCP/Public/MCPHandler.h +++ b/Plugins/BlueprintMCP/Source/BlueprintMCP/Public/MCPHandler.h @@ -50,5 +50,9 @@ public: virtual FString GetDescription() const = 0; // 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) {} }; diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Public/MCPServer.h b/Plugins/BlueprintMCP/Source/BlueprintMCP/Public/MCPServer.h index e61d35f7..3baada8a 100644 --- a/Plugins/BlueprintMCP/Source/BlueprintMCP/Public/MCPServer.h +++ b/Plugins/BlueprintMCP/Source/BlueprintMCP/Public/MCPServer.h @@ -53,8 +53,8 @@ private: TMap MCPHandlerRegistry; // tool name -> UMCPHandler subclass void BuildMCPHandlerRegistry(); - // Dispatch a tool call to the appropriate handler - void DispatchToolCall(const FString& ToolName, const FJsonObject* Params, FJsonObject* Result); + // Dispatch a tool call to the appropriate handler, returning the response string. + FString DispatchToolCall(const FString& ToolName, const FJsonObject* Params); // Handle a complete JSON line and return the response JSON FString HandleRequest(const FString& Line); diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Public/MCPUtils.h b/Plugins/BlueprintMCP/Source/BlueprintMCP/Public/MCPUtils.h index 09e4ec07..09974fd1 100644 --- a/Plugins/BlueprintMCP/Source/BlueprintMCP/Public/MCPUtils.h +++ b/Plugins/BlueprintMCP/Source/BlueprintMCP/Public/MCPUtils.h @@ -77,6 +77,7 @@ struct MCPErrorCallback MCPErrorCallback(std::nullptr_t); MCPErrorCallback(FString& OutError); MCPErrorCallback(FJsonObject* Result); + MCPErrorCallback(FStringBuilderBase& OutResult); void SetError(const FString& Msg) const { Func(Msg); } }; @@ -111,6 +112,12 @@ public: // ----- Enum helpers ----- // Convert enum value to string. If Prefix is specified, strip "Prefix_" from the front. + template + static FString EnumToString(TEnumAsByte Value, const FString& Prefix = FString()) + { + return EnumToString((T)Value, Prefix); + } + template static FString EnumToString(T Value, const FString& Prefix = FString()) { @@ -192,6 +199,10 @@ public: static bool PopulateFromJson(UStruct* StructType, void* Container, const TSharedPtr& JsonValue, 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: static FString SetPropertyFromJson(void* Container, FProperty* Prop, const FString& FieldName, const FJsonObject* Json); }; diff --git a/Source/Integration/Integration.cpp b/Source/Integration/Integration.cpp index 1740cf01..06c72d4d 100644 --- a/Source/Integration/Integration.cpp +++ b/Source/Integration/Integration.cpp @@ -7,79 +7,14 @@ #include "Misc/Paths.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"); void FlxIntegrationModuleImpl::StartupModule() { FString ShaderDir = FPaths::Combine(FPaths::ProjectDir(), TEXT("Shaders")); AddShaderSourceDirectoryMapping(TEXT("/Project/Integration"), ShaderDir); - -#if WITH_EDITOR - OnAssetSavedHandle = UPackage::PackageSavedWithContextEvent.AddRaw( - this, &FlxIntegrationModuleImpl::OnAssetSaved); -#endif } 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 AllObjects; - GetObjectsWithPackage(Package, AllObjects); - for (UObject *Obj : AllObjects) - { - if (UBlueprint *BP = Cast(Obj)) - { - FString BPDir = PkDir / BP->GetName(); - TArray 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 diff --git a/Source/Integration/Integration.h b/Source/Integration/Integration.h index c1c6b8d8..3e21f3f7 100644 --- a/Source/Integration/Integration.h +++ b/Source/Integration/Integration.h @@ -4,17 +4,10 @@ #include "CoreMinimal.h" #include "Modules/ModuleInterface.h" -#include "UObject/ObjectSaveContext.h" class FlxIntegrationModuleImpl : public IModuleInterface { public: virtual void StartupModule() override; virtual void ShutdownModule() override; - -private: -#if WITH_EDITOR - void OnAssetSaved(const FString& PackageFilename, UPackage* Package, FObjectPostSaveContext Context); - FDelegateHandle OnAssetSavedHandle; -#endif }; diff --git a/tools/inline-methods.py b/tools/inline-methods.py deleted file mode 100644 index e84bf338..00000000 --- a/tools/inline-methods.py +++ /dev/null @@ -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 - -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]} ", 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() diff --git a/tools/mcp-bridge.py b/tools/mcp-bridge.py index 4fb93d2f..bd17f986 100644 --- a/tools/mcp-bridge.py +++ b/tools/mcp-bridge.py @@ -68,7 +68,7 @@ def disconnect(): 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" sock.sendall(data.encode()) @@ -78,10 +78,10 @@ def send_and_receive(message): if not chunk: raise ConnectionError("Connection closed") result += chunk - try: - return json.loads(result) - except json.JSONDecodeError: - continue + if b"\0" in result: + break + + return result[:result.index(b"\0")].decode() def forward_to_editor(arguments): @@ -128,10 +128,8 @@ def handle_message(msg): params = msg.get("params", {}) arguments = params.get("arguments", {}) result = forward_to_editor(arguments) - is_error = "error" in result return make_jsonrpc(msg_id, { - "content": [{"type": "text", "text": json.dumps(result)}], - **({"isError": True} if is_error else {}), + "content": [{"type": "text", "text": result}], }) return { diff --git a/tools/mcp-test.py b/tools/mcp-test.py new file mode 100755 index 00000000..a53a9639 --- /dev/null +++ b/tools/mcp-test.py @@ -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()