From 341e694ab5a5e98606399ab6b6d25539eedd2199 Mon Sep 17 00:00:00 2001 From: Bonchellon Date: Wed, 24 Jun 2026 01:35:53 +0300 Subject: [PATCH] Add in-editor Slate setup wizard (Tools > MCP Setup Wizard) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Native UE editor tab (SMCPWizard) so setup happens inside Unreal, not just the Node CLI. Three sections: - Remote Control: live status of the :30010 bridge + a button to start it (WebControl.StartServer + EnableServerOnStartup). - API keys: paste/save the agent-gateway Anthropic token (auth.env) and ElevenLabs key (integrations.json) — both git-ignored. - MCP tools: lists the catalogue from tools.json. Wiring: - UEBlueprintMCPEditor module registers a nomad tab + a Tools-menu entry. - Build.cs gains the Projects module (IPluginManager). - server.js exports TOOLS and only launches the stdio server when run directly (isMain), so src/dumpTools.mjs can generate tools.json. `npm run dump-tools`. Co-Authored-By: Claude Opus 4.8 --- README.md | 13 + .../Private/SMCPWizard.cpp | 436 ++++++++++++++++++ .../UEBlueprintMCPEditor/Private/SMCPWizard.h | 49 ++ .../Private/UEBlueprintMCPEditor.cpp | 54 +++ .../Public/UEBlueprintMCPEditor.h | 3 + .../UEBlueprintMCPEditor.Build.cs | 1 + package.json | 1 + src/dumpTools.mjs | 18 + src/server.js | 34 +- tools.json | 202 ++++++++ 10 files changed, 799 insertions(+), 12 deletions(-) create mode 100644 Source/UEBlueprintMCPEditor/Private/SMCPWizard.cpp create mode 100644 Source/UEBlueprintMCPEditor/Private/SMCPWizard.h create mode 100644 src/dumpTools.mjs create mode 100644 tools.json diff --git a/README.md b/README.md index 907d760..b10c8f4 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,19 @@ It has two halves that ship together: Call `ping_ue` first to confirm the editor is reachable. +### In-editor setup wizard (Slate) + +The C++ editor module adds a native wizard tab: **Tools → MCP Setup Wizard**. It +lets you, without leaving Unreal: + +- see whether the Remote Control bridge (`:30010`) is up, and **start it** with one + click (`WebControl.StartServer` + enable-on-startup); +- paste & save the agent-gateway API keys (Anthropic token, ElevenLabs) into the + git-ignored `agent-gateway/` files; +- browse the list of MCP tools this server exposes (from `tools.json`). + +Regenerate the tools list after changing the catalogue with `npm run dump-tools`. + ### First-run check & `doctor` The first time the server starts it prints a one-time environment check to its diff --git a/Source/UEBlueprintMCPEditor/Private/SMCPWizard.cpp b/Source/UEBlueprintMCPEditor/Private/SMCPWizard.cpp new file mode 100644 index 0000000..9d8513e --- /dev/null +++ b/Source/UEBlueprintMCPEditor/Private/SMCPWizard.cpp @@ -0,0 +1,436 @@ +#include "SMCPWizard.h" + +#include "Engine/Engine.h" +#include "HttpModule.h" +#include "Interfaces/IHttpRequest.h" +#include "Interfaces/IHttpResponse.h" +#include "Interfaces/IPluginManager.h" +#include "Misc/FileHelper.h" +#include "Misc/Paths.h" +#include "HAL/FileManager.h" + +#include "Dom/JsonObject.h" +#include "Serialization/JsonReader.h" +#include "Serialization/JsonSerializer.h" + +#include "Framework/Notifications/NotificationManager.h" +#include "Widgets/Notifications/SNotificationList.h" + +#include "Styling/AppStyle.h" +#include "Styling/CoreStyle.h" +#include "Widgets/SBoxPanel.h" +#include "Widgets/Layout/SBorder.h" +#include "Widgets/Layout/SBox.h" +#include "Widgets/Layout/SScrollBox.h" +#include "Widgets/Input/SButton.h" +#include "Widgets/Input/SEditableTextBox.h" +#include "Widgets/Text/STextBlock.h" + +#define LOCTEXT_NAMESPACE "SMCPWizard" + +namespace +{ +// The Remote Control HTTP bridge always runs on localhost; default RC port. +const TCHAR* RemoteControlInfoUrl = TEXT("http://127.0.0.1:30010/remote/info"); + +TSharedRef MakeSection(const FText& Title, const FText& Subtitle, TSharedRef Content) +{ + return SNew(SBorder) + .BorderImage(FAppStyle::Get().GetBrush("ToolPanel.GroupBorder")) + .Padding(FMargin(14.f)) + [ + SNew(SVerticalBox) + + SVerticalBox::Slot().AutoHeight().Padding(0, 0, 0, 2) + [ + SNew(STextBlock) + .Text(Title) + .Font(FCoreStyle::GetDefaultFontStyle("Bold", 13)) + ] + + SVerticalBox::Slot().AutoHeight().Padding(0, 0, 0, 10) + [ + SNew(STextBlock) + .Text(Subtitle) + .AutoWrapText(true) + .ColorAndOpacity(FSlateColor::UseSubduedForeground()) + ] + + SVerticalBox::Slot().AutoHeight()[ Content ] + ]; +} +} // namespace + +void SMCPWizard::Construct(const FArguments& InArgs) +{ + ChildSlot + [ + SNew(SScrollBox) + + SScrollBox::Slot().Padding(16, 16, 16, 6) + [ + SNew(STextBlock) + .Text(LOCTEXT("Title", "Unreal Engine MCP — Setup Wizard")) + .Font(FCoreStyle::GetDefaultFontStyle("Bold", 18)) + ] + + // ---- 1. Remote Control -------------------------------------------- + + SScrollBox::Slot().Padding(16, 8) + [ + MakeSection( + LOCTEXT("RCTitle", "1 · Remote Control bridge"), + LOCTEXT("RCSub", "The MCP server talks to this editor over Remote Control (HTTP, port 30010). Start it here. Requires the 'Remote Control API' plugin to be enabled."), + SNew(SVerticalBox) + + SVerticalBox::Slot().AutoHeight().Padding(0, 0, 0, 10) + [ + SNew(STextBlock) + .Text(this, &SMCPWizard::GetRcStatusText) + .ColorAndOpacity(this, &SMCPWizard::GetRcStatusColor) + .Font(FCoreStyle::GetDefaultFontStyle("Bold", 11)) + ] + + SVerticalBox::Slot().AutoHeight() + [ + SNew(SHorizontalBox) + + SHorizontalBox::Slot().AutoWidth().Padding(0, 0, 8, 0) + [ + SNew(SButton) + .Text(LOCTEXT("StartRC", "Start Remote Control server")) + .ToolTipText(LOCTEXT("StartRCTip", "Runs WebControl.StartServer and enables it on startup.")) + .OnClicked(this, &SMCPWizard::OnStartRemoteControl) + ] + + SHorizontalBox::Slot().AutoWidth() + [ + SNew(SButton) + .Text(LOCTEXT("RefreshRC", "Refresh status")) + .OnClicked(this, &SMCPWizard::OnRefreshStatus) + ] + ] + ) + ] + + // ---- 2. API keys --------------------------------------------------- + + SScrollBox::Slot().Padding(16, 8) + [ + MakeSection( + LOCTEXT("KeysTitle", "2 · API keys"), + LOCTEXT("KeysSub", "Optional — only needed for the in-engine agent gateway. Stored locally in agent-gateway/ (git-ignored), never committed."), + SNew(SVerticalBox) + + SVerticalBox::Slot().AutoHeight().Padding(0, 0, 0, 4) + [ + SNew(STextBlock).Text(LOCTEXT("AnthropicLabel", "Anthropic token (CLAUDE_CODE_OAUTH_TOKEN)")) + ] + + SVerticalBox::Slot().AutoHeight().Padding(0, 0, 0, 6) + [ + SNew(SHorizontalBox) + + SHorizontalBox::Slot().FillWidth(1.f).Padding(0, 0, 8, 0) + [ + SAssignNew(AnthropicBox, SEditableTextBox) + .IsPassword(true) + .HintText(LOCTEXT("AnthropicHint", "sk-ant-oat01-...")) + ] + + SHorizontalBox::Slot().AutoWidth() + [ + SNew(SButton).Text(LOCTEXT("SaveAnthropic", "Save")).OnClicked(this, &SMCPWizard::OnSaveAnthropicToken) + ] + ] + + SVerticalBox::Slot().AutoHeight().Padding(0, 8, 0, 4) + [ + SNew(STextBlock).Text(LOCTEXT("ElevenLabel", "ElevenLabs API key (integrations.json)")) + ] + + SVerticalBox::Slot().AutoHeight() + [ + SNew(SHorizontalBox) + + SHorizontalBox::Slot().FillWidth(1.f).Padding(0, 0, 8, 0) + [ + SAssignNew(ElevenLabsBox, SEditableTextBox) + .IsPassword(true) + .HintText(LOCTEXT("ElevenHint", "sk_...")) + ] + + SHorizontalBox::Slot().AutoWidth() + [ + SNew(SButton).Text(LOCTEXT("SaveEleven", "Save")).OnClicked(this, &SMCPWizard::OnSaveElevenLabsKey) + ] + ] + ) + ] + + // ---- 3. Tools ------------------------------------------------------ + + SScrollBox::Slot().Padding(16, 8, 16, 16) + [ + MakeSection( + LOCTEXT("ToolsTitle", "3 · MCP tools"), + LOCTEXT("ToolsSub", "The tools this MCP server exposes to your client."), + BuildToolsSection()) + ] + ]; + + LoadExistingKeys(); + PingRemoteControl(); +} + +// --------------------------------------------------------------------------- +// Remote Control +// --------------------------------------------------------------------------- + +FText SMCPWizard::GetRcStatusText() const +{ + switch (RcStatus) + { + case ERCStatus::Up: return LOCTEXT("RcUp", "● Connected — Remote Control is reachable on :30010"); + case ERCStatus::Down: return LOCTEXT("RcDown", "● Not reachable on :30010 — start the server below"); + case ERCStatus::Checking: return LOCTEXT("RcChecking", "● Checking…"); + default: return LOCTEXT("RcUnknown", "● Status unknown"); + } +} + +FSlateColor SMCPWizard::GetRcStatusColor() const +{ + switch (RcStatus) + { + case ERCStatus::Up: return FSlateColor(FLinearColor(0.25f, 0.8f, 0.35f)); + case ERCStatus::Down: return FSlateColor(FLinearColor(0.9f, 0.35f, 0.3f)); + default: return FSlateColor::UseSubduedForeground(); + } +} + +void SMCPWizard::PingRemoteControl() +{ + RcStatus = ERCStatus::Checking; + Invalidate(EInvalidateWidgetReason::Paint); + + TWeakPtr WeakSelf = StaticCastSharedRef(AsShared()); + TSharedRef Request = FHttpModule::Get().CreateRequest(); + Request->SetVerb(TEXT("GET")); + Request->SetURL(RemoteControlInfoUrl); + Request->SetTimeout(3.0f); + Request->OnProcessRequestComplete().BindLambda( + [WeakSelf](FHttpRequestPtr, FHttpResponsePtr Resp, bool bSucceeded) + { + TSharedPtr Self = WeakSelf.Pin(); + if (!Self.IsValid()) + { + return; + } + const bool bOk = bSucceeded && Resp.IsValid() && Resp->GetResponseCode() == 200; + Self->RcStatus = bOk ? ERCStatus::Up : ERCStatus::Down; + Self->Invalidate(EInvalidateWidgetReason::Paint); + }); + Request->ProcessRequest(); +} + +FReply SMCPWizard::OnStartRemoteControl() +{ + if (GEngine) + { + GEngine->Exec(nullptr, TEXT("WebControl.EnableServerOnStartup 1")); + GEngine->Exec(nullptr, TEXT("WebControl.StartServer")); + } + Notify(LOCTEXT("RcStarted", "Remote Control server start requested."), true); + PingRemoteControl(); + return FReply::Handled(); +} + +FReply SMCPWizard::OnRefreshStatus() +{ + PingRemoteControl(); + return FReply::Handled(); +} + +// --------------------------------------------------------------------------- +// API keys +// --------------------------------------------------------------------------- + +FReply SMCPWizard::OnSaveAnthropicToken() +{ + const FString Value = AnthropicBox.IsValid() ? AnthropicBox->GetText().ToString().TrimStartAndEnd() : FString(); + const FString File = GatewayDir() / TEXT("auth.env"); + const bool bOk = UpsertEnvVar(File, TEXT("CLAUDE_CODE_OAUTH_TOKEN"), Value); + Notify(bOk + ? FText::Format(LOCTEXT("AnthropicSaved", "Saved to {0}"), FText::FromString(File)) + : LOCTEXT("AnthropicSaveFail", "Could not write auth.env"), bOk); + return FReply::Handled(); +} + +FReply SMCPWizard::OnSaveElevenLabsKey() +{ + const FString Value = ElevenLabsBox.IsValid() ? ElevenLabsBox->GetText().ToString().TrimStartAndEnd() : FString(); + const FString File = GatewayDir() / TEXT("integrations.json"); + + TSharedPtr Root = MakeShared(); + FString Existing; + if (FFileHelper::LoadFileToString(Existing, *File)) + { + TSharedRef> Reader = TJsonReaderFactory<>::Create(Existing); + TSharedPtr Parsed; + if (FJsonSerializer::Deserialize(Reader, Parsed) && Parsed.IsValid()) + { + Root = Parsed; + } + } + + TSharedPtr Eleven = Root->HasTypedField(TEXT("elevenlabs")) + ? Root->GetObjectField(TEXT("elevenlabs")) + : MakeShared(); + Eleven->SetStringField(TEXT("apiKey"), Value); + Root->SetObjectField(TEXT("elevenlabs"), Eleven); + + FString Out; + TSharedRef>> Writer = TJsonWriterFactory>::Create(&Out); + const bool bSer = FJsonSerializer::Serialize(Root.ToSharedRef(), Writer); + const bool bOk = bSer && FFileHelper::SaveStringToFile(Out, *File); + Notify(bOk + ? FText::Format(LOCTEXT("ElevenSaved", "Saved to {0}"), FText::FromString(File)) + : LOCTEXT("ElevenSaveFail", "Could not write integrations.json"), bOk); + return FReply::Handled(); +} + +void SMCPWizard::LoadExistingKeys() +{ + // Anthropic token from auth.env + FString AuthEnv; + if (AnthropicBox.IsValid() && FFileHelper::LoadFileToString(AuthEnv, *(GatewayDir() / TEXT("auth.env")))) + { + TArray Lines; + AuthEnv.ParseIntoArrayLines(Lines); + for (const FString& Line : Lines) + { + FString Trimmed = Line.TrimStartAndEnd(); + if (Trimmed.StartsWith(TEXT("CLAUDE_CODE_OAUTH_TOKEN="))) + { + AnthropicBox->SetText(FText::FromString(Trimmed.RightChop(24))); + break; + } + } + } + + // ElevenLabs key from integrations.json + FString Json; + if (ElevenLabsBox.IsValid() && FFileHelper::LoadFileToString(Json, *(GatewayDir() / TEXT("integrations.json")))) + { + TSharedRef> Reader = TJsonReaderFactory<>::Create(Json); + TSharedPtr Root; + if (FJsonSerializer::Deserialize(Reader, Root) && Root.IsValid() && Root->HasTypedField(TEXT("elevenlabs"))) + { + const FString Key = Root->GetObjectField(TEXT("elevenlabs"))->GetStringField(TEXT("apiKey")); + ElevenLabsBox->SetText(FText::FromString(Key)); + } + } +} + +// --------------------------------------------------------------------------- +// Tools +// --------------------------------------------------------------------------- + +TSharedRef SMCPWizard::BuildToolsSection() +{ + const FString File = PluginDir() / TEXT("tools.json"); + FString Json; + if (!FFileHelper::LoadFileToString(Json, *File)) + { + return SNew(STextBlock) + .AutoWrapText(true) + .Text(LOCTEXT("NoTools", "tools.json not found. Run `npm run dump-tools` in the plugin folder to generate the catalogue.")); + } + + TArray> Items; + TSharedRef> Reader = TJsonReaderFactory<>::Create(Json); + if (!FJsonSerializer::Deserialize(Reader, Items)) + { + return SNew(STextBlock).Text(LOCTEXT("BadTools", "tools.json could not be parsed.")); + } + + TSharedRef List = SNew(SVerticalBox); + for (const TSharedPtr& Item : Items) + { + const TSharedPtr Obj = Item.IsValid() ? Item->AsObject() : nullptr; + if (!Obj.IsValid()) + { + continue; + } + const FString Name = Obj->GetStringField(TEXT("name")); + const FString Desc = Obj->GetStringField(TEXT("description")); + List->AddSlot().AutoHeight().Padding(0, 4) + [ + SNew(SVerticalBox) + + SVerticalBox::Slot().AutoHeight() + [ + SNew(STextBlock).Text(FText::FromString(Name)).Font(FCoreStyle::GetDefaultFontStyle("Bold", 10)) + ] + + SVerticalBox::Slot().AutoHeight() + [ + SNew(STextBlock) + .Text(FText::FromString(Desc)) + .AutoWrapText(true) + .ColorAndOpacity(FSlateColor::UseSubduedForeground()) + ] + ]; + } + + return SNew(SBox).MaxDesiredHeight(320.f) + [ + SNew(SScrollBox) + SScrollBox::Slot()[ List ] + ]; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +FString SMCPWizard::PluginDir() const +{ + TSharedPtr Plugin = IPluginManager::Get().FindPlugin(TEXT("UEBlueprintMCP")); + if (Plugin.IsValid()) + { + return FPaths::ConvertRelativePathToFull(Plugin->GetBaseDir()); + } + return FPaths::ConvertRelativePathToFull(FPaths::ProjectPluginsDir() / TEXT("UEBlueprintMCP")); +} + +FString SMCPWizard::GatewayDir() const +{ + return PluginDir() / TEXT("agent-gateway"); +} + +void SMCPWizard::Notify(const FText& Message, bool bSuccess) +{ + FNotificationInfo Info(Message); + Info.ExpireDuration = bSuccess ? 4.0f : 7.0f; + Info.bUseSuccessFailIcons = true; + Info.bFireAndForget = true; + TSharedPtr Item = FSlateNotificationManager::Get().AddNotification(Info); + if (Item.IsValid()) + { + Item->SetCompletionState(bSuccess ? SNotificationItem::CS_Success : SNotificationItem::CS_Fail); + } +} + +bool SMCPWizard::UpsertEnvVar(const FString& FilePath, const FString& Key, const FString& Value) +{ + FString Content; + FFileHelper::LoadFileToString(Content, *FilePath); // ok if missing + + TArray Lines; + Content.ParseIntoArray(Lines, TEXT("\n"), false); + + bool bReplaced = false; + const FString Prefix = Key + TEXT("="); + for (FString& Line : Lines) + { + if (Line.TrimStartAndEnd().StartsWith(Prefix)) + { + Line = Prefix + Value; + bReplaced = true; + break; + } + } + if (!bReplaced) + { + Lines.Add(Prefix + Value); + } + + // Drop trailing empties for a tidy file, then join with LF. + while (Lines.Num() > 0 && Lines.Last().TrimStartAndEnd().IsEmpty()) + { + Lines.Pop(); + } + const FString Out = FString::Join(Lines, TEXT("\n")) + TEXT("\n"); + return FFileHelper::SaveStringToFile(Out, *FilePath); +} + +#undef LOCTEXT_NAMESPACE diff --git a/Source/UEBlueprintMCPEditor/Private/SMCPWizard.h b/Source/UEBlueprintMCPEditor/Private/SMCPWizard.h new file mode 100644 index 0000000..02a7cc1 --- /dev/null +++ b/Source/UEBlueprintMCPEditor/Private/SMCPWizard.h @@ -0,0 +1,49 @@ +#pragma once + +#include "CoreMinimal.h" +#include "Widgets/SCompoundWidget.h" +#include "Styling/SlateColor.h" + +class SEditableTextBox; + +/** + * In-editor setup wizard for the UE Blueprint MCP plugin. A Nomad tab opened from + * Tools > MCP Setup Wizard. Three sections: + * 1. Remote Control — show status of the HTTP bridge (:30010) and start it. + * 2. API keys — paste the agent-gateway Anthropic token + ElevenLabs key. + * 3. MCP tools — list the tool catalogue (read from the plugin's tools.json). + */ +class SMCPWizard : public SCompoundWidget +{ +public: + SLATE_BEGIN_ARGS(SMCPWizard) {} + SLATE_END_ARGS() + + void Construct(const FArguments& InArgs); + +private: + // --- Remote Control --- + enum class ERCStatus : uint8 { Unknown, Checking, Up, Down }; + ERCStatus RcStatus = ERCStatus::Unknown; + FText GetRcStatusText() const; + FSlateColor GetRcStatusColor() const; + FReply OnStartRemoteControl(); + FReply OnRefreshStatus(); + void PingRemoteControl(); + + // --- API keys --- + TSharedPtr AnthropicBox; + TSharedPtr ElevenLabsBox; + FReply OnSaveAnthropicToken(); + FReply OnSaveElevenLabsKey(); + void LoadExistingKeys(); + + // --- Tools --- + TSharedRef BuildToolsSection(); + + // --- helpers --- + FString PluginDir() const; + FString GatewayDir() const; + static void Notify(const FText& Message, bool bSuccess); + static bool UpsertEnvVar(const FString& FilePath, const FString& Key, const FString& Value); +}; diff --git a/Source/UEBlueprintMCPEditor/Private/UEBlueprintMCPEditor.cpp b/Source/UEBlueprintMCPEditor/Private/UEBlueprintMCPEditor.cpp index 2a4ffbe..0c607c9 100644 --- a/Source/UEBlueprintMCPEditor/Private/UEBlueprintMCPEditor.cpp +++ b/Source/UEBlueprintMCPEditor/Private/UEBlueprintMCPEditor.cpp @@ -20,6 +20,10 @@ #include "ToolMenuEntry.h" #include "ToolMenuSection.h" #include "ToolMenus.h" +#include "Framework/Docking/TabManager.h" +#include "Widgets/Docking/SDockTab.h" +#include "Styling/AppStyle.h" +#include "SMCPWizard.h" #define LOCTEXT_NAMESPACE "UEBlueprintMCPEditor" @@ -27,6 +31,7 @@ namespace { const TCHAR* MCPZoneCommentText = TEXT("MCP Zone"); const TCHAR* LegacyMCPZoneCommentText = TEXT("MCP Draft Zone"); +const FName MCPWizardTabId(TEXT("MCPSetupWizard")); bool IsMCPZoneComment(const UEdGraphNode* Node) { @@ -150,6 +155,12 @@ void FUEBlueprintMCPEditorModule::ShutdownModule() { FCoreDelegates::OnPostEngineInit.RemoveAll(this); + if (bWizardTabRegistered && FSlateApplication::IsInitialized()) + { + FGlobalTabmanager::Get()->UnregisterNomadTabSpawner(MCPWizardTabId); + bWizardTabRegistered = false; + } + if (MCPZoneInputProcessor.IsValid() && FSlateApplication::IsInitialized()) { FSlateApplication::Get().UnregisterInputPreProcessor(MCPZoneInputProcessor); @@ -163,9 +174,52 @@ void FUEBlueprintMCPEditorModule::ShutdownModule() } } +void FUEBlueprintMCPEditorModule::RegisterWizardTabAndMenu() +{ + // Nomad tab that hosts the wizard. + if (!bWizardTabRegistered && FSlateApplication::IsInitialized()) + { + FGlobalTabmanager::Get()->RegisterNomadTabSpawner( + MCPWizardTabId, + FOnSpawnTab::CreateRaw(this, &FUEBlueprintMCPEditorModule::SpawnWizardTab)) + .SetDisplayName(LOCTEXT("MCPWizardTabTitle", "MCP Setup Wizard")) + .SetTooltipText(LOCTEXT("MCPWizardTabTooltip", "Set up the UE Blueprint MCP: Remote Control, API keys, and the tool list.")) + .SetMenuType(ETabSpawnerMenuType::Hidden); + bWizardTabRegistered = true; + } + + // Tools menu entry to open it. + UToolMenu* ToolsMenu = UToolMenus::Get()->ExtendMenu("LevelEditor.MainMenu.Tools"); + if (ToolsMenu) + { + FToolMenuSection& Section = ToolsMenu->FindOrAddSection("MCP", LOCTEXT("MCPMenuSection", "MCP")); + Section.AddMenuEntry( + "OpenMCPSetupWizard", + LOCTEXT("OpenMCPWizard", "MCP Setup Wizard"), + LOCTEXT("OpenMCPWizardTip", "Open the UE Blueprint MCP setup wizard (Remote Control, API keys, tools)."), + FSlateIcon(FAppStyle::GetAppStyleSetName(), "Icons.Settings"), + FUIAction(FExecuteAction::CreateLambda([]() + { + FGlobalTabmanager::Get()->TryInvokeTab(MCPWizardTabId); + }))); + } +} + +TSharedRef FUEBlueprintMCPEditorModule::SpawnWizardTab(const FSpawnTabArgs& Args) +{ + return SNew(SDockTab) + .TabRole(ETabRole::NomadTab) + [ + SNew(SMCPWizard) + ]; +} + void FUEBlueprintMCPEditorModule::RegisterMenus() { FToolMenuOwnerScoped OwnerScoped(this); + + RegisterWizardTabAndMenu(); + UToolMenu* Toolbar = UToolMenus::Get()->ExtendMenu("AssetEditor.BlueprintEditor.ToolBar"); FToolMenuSection& Section = Toolbar->FindOrAddSection("Settings"); diff --git a/Source/UEBlueprintMCPEditor/Public/UEBlueprintMCPEditor.h b/Source/UEBlueprintMCPEditor/Public/UEBlueprintMCPEditor.h index 7a9bb61..8f9633e 100644 --- a/Source/UEBlueprintMCPEditor/Public/UEBlueprintMCPEditor.h +++ b/Source/UEBlueprintMCPEditor/Public/UEBlueprintMCPEditor.h @@ -18,6 +18,8 @@ private: friend class FMCPZoneInputProcessor; void RegisterMenus(); + void RegisterWizardTabAndMenu(); + TSharedRef SpawnWizardTab(const class FSpawnTabArgs& Args); void NotifyBridgeStatus(); void ToggleMCPZoneMode(TWeakPtr BlueprintEditor); bool IsMCPZoneModeActive(TWeakPtr BlueprintEditor) const; @@ -26,6 +28,7 @@ private: void RemoveMCPZones(UBlueprint* Blueprint) const; bool bMCPZoneModeActive = false; + bool bWizardTabRegistered = false; TWeakPtr ActiveMCPZoneEditor; TSharedPtr MCPZoneInputProcessor; }; diff --git a/Source/UEBlueprintMCPEditor/UEBlueprintMCPEditor.Build.cs b/Source/UEBlueprintMCPEditor/UEBlueprintMCPEditor.Build.cs index f58976d..d417933 100644 --- a/Source/UEBlueprintMCPEditor/UEBlueprintMCPEditor.Build.cs +++ b/Source/UEBlueprintMCPEditor/UEBlueprintMCPEditor.Build.cs @@ -31,6 +31,7 @@ public class UEBlueprintMCPEditor : ModuleRules "EditorSubsystem", "ToolMenus", "AssetTools", + "Projects", "Json", "JsonUtilities", "AssetRegistry", diff --git a/package.json b/package.json index 7152044..02090df 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "start": "node src/server.js", "setup": "node src/setup.js", "doctor": "node src/doctor.js", + "dump-tools": "node src/dumpTools.mjs", "test": "node --test \"test/*.test.js\"" }, "dependencies": { diff --git a/src/dumpTools.mjs b/src/dumpTools.mjs new file mode 100644 index 0000000..9b55602 --- /dev/null +++ b/src/dumpTools.mjs @@ -0,0 +1,18 @@ +// dumpTools.mjs — write tools.json (the MCP tool catalogue) next to the plugin +// root, so the in-editor Slate wizard can list the available tools without +// running Node. Regenerate after changing the TOOLS array: npm run dump-tools +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { TOOLS } from "./server.js"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const out = path.resolve(here, "..", "tools.json"); + +const simplified = TOOLS.map((t) => ({ + name: t.name, + description: (t.description || "").trim(), +})); + +fs.writeFileSync(out, JSON.stringify(simplified, null, 2) + "\n", "utf8"); +console.log(`wrote ${simplified.length} tools to ${out}`); diff --git a/src/server.js b/src/server.js index 5c7000d..f9a06c8 100644 --- a/src/server.js +++ b/src/server.js @@ -13,6 +13,7 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import path from "node:path"; +import { pathToFileURL } from "node:url"; import { CallToolRequestSchema, ListToolsRequestSchema, @@ -972,6 +973,10 @@ const TOOLS = [ }, ]; +// Exported so tooling (e.g. the in-editor Slate wizard's tools.json generator) +// can read the catalogue without launching the stdio server. +export { TOOLS }; + const server = new Server( { name: "ue-blueprint-mcp", version: "0.1.0" }, { capabilities: { tools: {} } } @@ -1345,16 +1350,21 @@ function errResult(msg) { return { content: [{ type: "text", text: msg }], isErr // instead we print a one-time diagnostic to stderr (visible in the client's MCP // logs) pointing the user at `npm run setup`, then drop a marker so it's quiet // afterwards. Best-effort: never let setup diagnostics block or break startup. -if (PP.isFirstRun()) { - try { - const res = await runDiagnostics({ deep: true }); - process.stderr.write("\n[ue-mcp] First run — environment check:\n\n" + formatReport(res) + "\n"); - if (!res.ok) { - process.stderr.write("\n[ue-mcp] Run `npm run setup` in the plugin folder to fix the items above, " + - "or call the `doctor` tool from your client.\n\n"); - } - } catch { /* ignore — diagnostics are advisory only */ } - PP.markRun({ via: "server-first-run" }); -} +// Only launch the stdio server when run directly (`node src/server.js`). When +// imported as a module (e.g. by the tools.json generator) we just expose TOOLS. +const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; +if (isMain) { + if (PP.isFirstRun()) { + try { + const res = await runDiagnostics({ deep: true }); + process.stderr.write("\n[ue-mcp] First run — environment check:\n\n" + formatReport(res) + "\n"); + if (!res.ok) { + process.stderr.write("\n[ue-mcp] Run `npm run setup` in the plugin folder to fix the items above, " + + "or call the `doctor` tool from your client.\n\n"); + } + } catch { /* ignore — diagnostics are advisory only */ } + PP.markRun({ via: "server-first-run" }); + } -await server.connect(new StdioServerTransport()); + await server.connect(new StdioServerTransport()); +} diff --git a/tools.json b/tools.json new file mode 100644 index 0000000..4c09e6d --- /dev/null +++ b/tools.json @@ -0,0 +1,202 @@ +[ + { + "name": "apply_graph", + "description": "Spawn and connect Blueprint nodes from a declarative NodeSpec graph. Atomic in one undo step. mode=append|replace_function|replace_event. Explicit node positions are preserved by default; pass layout=compact or auto_layout=true to repack columns. dry_run=true returns generated Python without sending. validate=true resolves all targets in UE without spawning (use before big batches)." + }, + { + "name": "introspect", + "description": "List callable functions / properties of a UE class so you can pick valid `target` values for call_function / variable_* nodes. Args: { class_path: '/Script/Engine.KismetSystemLibrary' | 'KismetMathLibrary' }." + }, + { + "name": "compile", + "description": "Compile a Blueprint asset and return compile errors/warnings." + }, + { + "name": "read_graph", + "description": "Dump an existing Blueprint graph as JSON (nodes + pins + connections). Use to inspect a graph before editing." + }, + { + "name": "apply_batch", + "description": "Run multiple apply_graph operations in one round-trip (same UE transaction, single undo). Each op is a graph spec like apply_graph args. Order is preserved." + }, + { + "name": "ping_ue", + "description": "Check whether UE Remote Control is reachable. Useful as a first call in any session." + }, + { + "name": "doctor", + "description": "First-run / setup diagnostics: verifies project & engine detection, Node deps, Remote Control reachability, the Python plugin, and whether the C++ module is built. Returns each check with a suggested fix. Run this when something isn't working, or guide a new user through it. For interactive fixes the user runs `npm run setup` in the plugin folder." + }, + { + "name": "get_active_blueprint", + "description": "Return the path of the Blueprint currently open in the UE editor (most-recently-focused). Use to verify auto-detect target before calling apply_graph without bp_path." + }, + { + "name": "read_last_result", + "description": "Read the result envelope of the most recent apply_graph call from /Saved/MCP/last.json. Use after apply_graph to inspect spawned/errors when stdout isn't available." + }, + { + "name": "discover_op", + "description": "Read-only discovery. Ops: list_open_bps{}, list_graphs{bp_path}, describe_bp{bp_path}, get_selection{bp_path}, resolve_call{bp_path,function?,guid}, find_var_refs{bp_path,name}, find_fn_refs{bp_path,name}, find_bp_by_parent{parent_class_path}, get_refs_of{asset_path}, get_referencers{asset_path}, read_function{bp_path,function}." + }, + { + "name": "edit_op", + "description": "Single-node edits + navigation. Ops: delete_node{bp_path,function?,guid}, break_link{bp_path,function?,from:[guid,pin],to:[guid,pin]}, break_all{bp_path,function?,guid}, move_nodes{bp_path,function?,moves:[{guid,x,y}]}, resize_comment{bp_path,function?,guid,width,height}, set_comment{bp_path,function?,guid,text,bubble?}, open_bp{bp_path}, open_graph{bp_path,function}, jump_to_node{bp_path,function?,guid}, add_case_pin{bp_path,function?,guid}, format_text{bp_path,function?,guid,format}, reconstruct{bp_path,function?,guid}." + }, + { + "name": "selection_op", + "description": "Selection-aware partial Blueprint graph work. Ops: get_context{bp_path,function?} returns selected nodes, focused graph, bbox, anchors and MCP Draft Zones; apply_relative{bp_path,function?,zone_guid?,placement:right|below|center,gap_x?,gap_y?,delete_selection?,nodes,edges} appends or replaces only a selected area / draft zone by offsetting NodeSpec positions." + }, + { + "name": "network_op", + "description": "Blueprint networking operations. Ops: audit_bp{bp_path,components?}, set_actor_flags{bp_path,replicates?,replicate_movement?,net_load_on_client?,always_relevant?,only_relevant_to_owner?,use_owner_relevancy?,net_cull_distance_squared?,net_update_frequency?,min_net_update_frequency?,net_priority?,net_dormancy?}, set_component_flags{bp_path,component,replicated?,net_addressable?}, set_custom_event_rpc{bp_path,function?,event?|guid?,mode:none|server|client|multicast,reliable?}, apply_profile{bp_path,profile:replicated_world_actor|always_relevant_world_actor}." + }, + { + "name": "voxel_graph_op", + "description": "Voxel Plugin graph worker. Ops: list_terminals{asset_path}, read_graph{asset_path,terminal?}, get_selection{asset_path,terminal?}, get_context{asset_path,terminal?}, list_node_types{filter?,limit?} (catalogue of spawnable nodes; returns {id,display_name,category,is_pure,kind}), spawn_node{asset_path,terminal?,node,x,y,compile?} (node = id/display name from list_node_types; returns guid), create_graph{path} (new empty Voxel Graph), add_property{asset_path,terminal?,kind,name,type?,category?} (kind=parameter|input|output|local; type=float|double|int|bool|vector2d|vector|color|name; returns guid), list_properties{asset_path,terminal?,kind}, spawn_property{asset_path,terminal?,kind,ref,x,y,declaration?,compile?} (ref=guid/name; declaration only for local), spawn_call{asset_path,terminal?,function,x,y,compile?} (function=function-terminal guid/name), compile{asset_path,terminal?}, spawn_comment{asset_path,terminal?,x,y,width,height,text?,color?}, clear_zones{asset_path,terminal?}, delete_node{asset_path,terminal?,guid}, break_link{asset_path,terminal?,from:[guid,pin],to:[guid,pin]}, break_all{asset_path,terminal?,guid}, connect_pins{asset_path,terminal?,from:[guid,pin],to:[guid,pin]}, set_pin_default{asset_path,terminal?,guid,pin,value}, move_nodes{asset_path,terminal?,moves:[{guid,x,y}]}, resize_comment{asset_path,terminal?,guid,width,height}, set_comment{asset_path,terminal?,guid,text,bubble?}, open_asset{asset_path}. Typical flow: create_graph -> (add_property params) -> list_node_types -> spawn_node/spawn_property -> read_graph (see pins) -> connect_pins / set_pin_default -> compile." + }, + { + "name": "pcg_op", + "description": "PCG (Procedural Content Generation) workflow worker — end-to-end: author graphs, lay them out, wire subgraphs, then spawn/generate them in the level. Pure-Python (engine-exposed UPCGGraph), supports ALL PCG node types — every UPCGSettings subclass bound into Python (engine PCG + PCGExtendedToolkit), enumerated dynamically (no hardcoded table). AUTHORING: api{} (dump live PCG reflection), create_graph{path} (new UPCGGraph), list_node_types{filter?,limit?} (catalogue; returns {name,title,plugin,path}), read_graph{asset_path} (nodes+pins+edges+io), add_node{asset_path,node,x?,y?,title?}, delete_node{asset_path,node}, connect{asset_path,from:[node,pin?],to:[node,pin?]} (pin optional → first pin), disconnect{...}, list_node_properties{asset_path,node}, set_node_property{asset_path,node,property,value}, move_nodes{asset_path,moves:[{node,x,y}]}, save{asset_path}, open_asset{asset_path}. DECLARATIVE: apply_graph{asset_path,nodes:[{id,type,x?,y?,title?,properties?}],edges:[{from:[id,pin?],to:[id,pin?]}],outputs?:[{from:[id,pin?],pin?}],clear_existing?,auto_layout?(default true),save?(default true)} builds a whole graph in one call (auto-laid-out by edge depth); auto_layout|tidy_graph|layout{asset_path} re-flows existing nodes. PARAMS/SUBGRAPH: list_params{asset_path} (read-only — UE blocks Python property-bag editing); set_subgraph{asset_path,node,subgraph} (point a Subgraph node at another PCG graph asset). RUNTIME: spawn_volume{graph?,location?,rotation?,scale?,name?,generate?} (spawn a PCG Volume, assign graph, generate), set_actor_graph{actor,graph,generate?}, generate{actor,force?}, cleanup{actor,remove_components?}, list_components{} (level actors with a PCGComponent + their graph/state). PRESETS: scaffold{path|asset_path,template,create?,save?} builds a working graph from a template (template='__list__' → surface_scatter | points_grid_spawn | density_filter_scatter). Node ref = 'input'|'output'|index|UObject name|settings class short-name/title. Typical flow: scaffold OR apply_graph → read_graph → set_node_property → spawn_volume(generate) → list_components. If a call fails on a method name, run api{}." + }, + { + "name": "material_op", + "description": "Material authoring and material instance ops. Ops: create_material{path}, create_material_instance{path,parent?}, read_graph{asset_path}, apply_graph{asset_path,clear_existing?,auto_layout?,layout?,save?,nodes:[{id,class,position?,properties?}],edges:[{from:[idOrGuid,output],to:[idOrGuid,input]}],outputs:{BaseColor:[idOrGuid,output]}}, add_node{asset_path,class,position?,properties?}, delete_node{asset_path,guid|name}, move_nodes{asset_path,moves:[{guid|name,x,y}]}, set_node_property{asset_path,guid|name,property,value}, connect{asset_path,from:[guid|name,output],to:[guid|name,input]}, connect_output{asset_path,from:[guid|name,output],property}, layout{asset_path}, tidy_graph{asset_path}, recompile_save{asset_path}, set_instance_parent{asset_path,parent}, set_instance_scalar{asset_path,name,value}, set_instance_vector{asset_path,name,value}, set_instance_texture{asset_path,name,value}, list_parameters{asset_path}, open_asset{asset_path}." + }, + { + "name": "preset_op", + "description": "Apply a packaged Blueprint pattern. Send name='__list__' to see available presets. Built-in: print_on_begin_play{bp_path,text?}, print_on_event{bp_path,event,text?}, toggle_visibility{bp_path,event?,component}, damage_drops_health{bp_path,health_var?}." + }, + { + "name": "asset_op", + "description": "Asset CRUD. Ops: create_blueprint{path,parent_class}, create_widget_bp{path}, create_enum{path,values}, create_struct{path}, create_data_asset{path,class}, create_data_table{path,row_struct}, duplicate{src,dst}, rename{src,dst}, move{src,dst_folder}, delete{path}, reparent_blueprint{bp,new_parent_class}." + }, + { + "name": "variable_op", + "description": "Blueprint member-variable authoring (the apply_graph node-spec can reference variables but cannot CREATE them — use this first). Ops: add{bp_path,name,type,default?,instance_editable?,expose_on_spawn?,category?,tooltip?} — type is bool|int|float|double|string|name|text or a class path/short-name for an object ref; set_default{bp_path,name,value}, set_meta{bp_path,name,instance_editable?,expose_on_spawn?,category?,tooltip?}, rename{bp_path,old,new}, delete{bp_path,name}, list{bp_path}. Compiles + saves the Blueprint after each mutating op so subsequent variable_get/variable_set nodes resolve." + }, + { + "name": "widget_op", + "description": "UMG Designer / WidgetTree ops. Ops: describe{bp_path}, get_props{bp_path,widget}, get_slot_props{bp_path,widget}, add_widget{bp_path,class,name?,parent?,index?,is_variable?,properties?,canvas_slot?}, remove_widget{bp_path,widget}, rename_widget{bp_path,widget,new_name}, move_widget{bp_path,widget,parent?,index?}, set_root{bp_path,widget}, set_is_variable{bp_path,widget,is_variable}, set_property{bp_path,widget,property,value}, set_properties{bp_path,widget,properties}, set_slot_property{bp_path,widget,property,value}, set_canvas_slot{bp_path,widget,position?,size?,anchors_min?,anchors_max?,alignment?,auto_size?,z_order?}, set_named_slot{bp_path,host,slot,content}, clear_named_slot{bp_path,host,slot}, set_desired_focus{bp_path,widget}, apply_tree{bp_path,clear_existing?,widgets:[...]}, compile_save{bp_path}, screenshot{bp_path,size?:[1920,1080],out_dir?:'MCP/widgets'} — VISION LOOP: renders the WBP to a PNG and returns file:// path. Use AFTER any UI change to verify visual result. AI MUST attach the returned image to its next thought before iterating." + }, + { + "name": "level_op", + "description": "Level/scene ops. Ops: spawn_actor{class,location?,rotation?,scale?,name?}, destroy{name}, get_selected{}, select{names}, get_by_class{class}, find_by_name{name}, set_transform{name,location?,rotation?,scale?}, attach{child,parent,socket?}, save_level{}, load_level{path}, current_level{}." + }, + { + "name": "niagara_op", + "description": "Niagara VFX authoring. Requires C++ rebuild (MCPNiagaraLibrary). Ops: create_system{path}, create_emitter_asset{path}, add_emitter{system,emitter_asset,name?}, remove_emitter{system,emitter_name}, list_emitters{system}, list_modules{system,emitter_name?,group}, add_module{system,emitter_name?,group,module_script,index?}, remove_module{system,emitter_name?,group,module_name}, add_renderer{system,emitter_name,renderer_class}, remove_renderer{system,emitter_name,renderer_class}, compile{system}. Groups: EmitterSpawn|EmitterUpdate|ParticleSpawn|ParticleUpdate|SystemSpawn|SystemUpdate. set_module_input is stubbed (use UI for now)." + }, + { + "name": "animation_op", + "description": "Animation authoring. Ops: create_anim_blueprint{path,skeleton}, create_anim_montage{path,sequence?}, create_blend_space{path,skeleton,dim?:1|2}, get_sequence_info{path}, list_anim_assets{folder?,skeleton?}, list_sockets{skeleton}, add_socket{skeleton,name,bone,location?,rotation?}, remove_socket{skeleton,name}, list_montage_sections{path}, add_montage_section{path,name,start_time?}, list_notifies{sequence}, add_notify{sequence,name,time,notify_class?}, remove_notify{sequence,index}, list_state_machines{anim_bp}, retarget_animation{src,dst_skeleton,dst_path}." + }, + { + "name": "audio_op", + "description": "Audio assets. Ops: create_metasound_source{path}, create_metasound_patch{path}, create_sound_cue{path}, create_sound_attenuation{path}, create_sound_class{path}, create_sound_mix{path}, set_property{path,property,value}, get_property{path,property}, list_sound_assets{folder?}, play_sound_2d{path}." + }, + { + "name": "ai_op", + "description": "AI assets (BehaviorTree, Blackboard, EQS, AIController BP). Ops: create_behavior_tree{path,blackboard?}, create_blackboard{path,parent?}, create_eqs_query{path}, create_aicontroller_bp{path,parent_class?}, add_blackboard_key{path,name,type:'Bool|Int|Float|String|Name|Vector|Rotator|Object|Class|Enum',base_class?}, remove_blackboard_key{path,name}, list_blackboard_keys{path}, set_bt_blackboard{bt_path,blackboard_path}." + }, + { + "name": "input_op", + "description": "Enhanced Input authoring. Ops: create_input_action{path,value_type?:'Bool|Axis1D|Axis2D|Axis3D'}, create_input_mapping_context{path}, add_mapping{imc_path,action_path,key,modifiers?,triggers?}, remove_mapping{imc_path,index}, list_mappings{imc_path}. modifiers/triggers are arrays of UClass paths e.g. '/Script/EnhancedInput.InputTriggerHold'." + }, + { + "name": "mesh_op", + "description": "Static / Skeletal mesh ops. Ops: static_info{path}, skeletal_info{path}, list_sockets{path}, add_socket{path,name,bone?,location?,rotation?,scale?}, remove_socket{path,name}, list_lods{path}, set_lod_screen_size{path,lod_index,screen_size}, remove_lod{path,lod_index}, set_collision_complexity{path,complexity:'Default|UseSimpleAsComplex|UseComplexAsSimple'}, add_simple_collision{path,shape:'Box|Sphere|Capsule'}." + }, + { + "name": "render_op", + "description": "Render-side ops. Ops: spawn_post_process_volume{location?,unbound?:true,settings?,name?}, set_pp_setting{actor_name,key,value}, list_pp_volumes{}, create_render_target_2d{path,width?,height?,format?:'RGBA16f|RGBA8'}, spawn_scene_capture_2d{target_render_target_path,location?,rotation?,name?}, take_screenshot{filename?,resolution?,hdr?}, set_viewport_realtime{enabled}, capture_thumbnail{asset_path}." + }, + { + "name": "capture_op", + "description": "HEADLESS VISION — see editor content WITHOUT opening any tab/window. Returns a PNG INLINE (you see the image directly) plus its file path. Ops: asset{asset_path,size?:[512,512]} renders ANY asset's thumbnail offscreen (mesh, material, texture, blueprint, niagara, anim, sound, data asset — no asset editor needed); thumbnail/material/mesh are aliases of asset; scene{location?:[x,y,z],rotation?:[pitch,yaw,roll],fov?:90,size?:[1280,720]} renders the editor world from an arbitrary camera via offscreen SceneCapture2D (no viewport); scene_from_actor{actor,distance?,pitch?:-20,yaw?:0,fov?:75,size?} frames a level actor and captures it; list_assets{dir,recursive?} lists asset paths to capture. Prefer this over render_op take_screenshot (which grabs the live viewport)." + }, + { + "name": "cull_op", + "description": "Batch cull-distance (Desired Max Draw Distance) on ISM/HISM/StaticMesh components. Ops: audit{scope?,asset_paths?,component_types?,target?} (read-only report), apply{distance,scope?,asset_paths?,component_types?,only_zero?:true,target?,save?:true}, verify{...} (alias of audit). target='asset_template' (default) edits the Blueprint asset = default for all instances, survives reconstruct; target='level_instance' edits placed actors only (lost on BP recompile / PLA repack). scope='level'(default)|'assets'|'selection'. component_types default ['ISM']. only_zero keeps existing non-zero values." + }, + { + "name": "console_op", + "description": "Editor console commands and CVars. Ops: run{command}, get_cvar{name}, set_cvar{name,value}, list_cvars{prefix?}, stat{name,enable?}. Use read_python_log to read echoed output afterwards." + }, + { + "name": "validation_op", + "description": "Asset validation, redirector cleanup, reference analysis. Ops: validate_assets{paths:[]}, validate_folder{path,recursive?}, fix_redirectors{path}, get_references{asset_path}, get_referencers{asset_path}, find_unused{folder,ignore_paths?}, disk_size{folder}, list_redirectors{folder}." + }, + { + "name": "live_coding_op", + "description": "Live Coding control. Use BEFORE assuming a C++ rebuild will work — checks status, triggers compile, parses LiveCodingConsole.log for results. Ops: status{}, compile{}, enable{}, disable{}, open_lc_console{}, get_errors{tail?:200}, last_compile{} — last_compile returns {status:'success|failed|in_progress|no_recent', duration_s, timestamp, errors, cycle_lines} parsed from Engine/Programs/LiveCodingConsole/Saved/Logs." + }, + { + "name": "cpp_scaffold_op", + "description": "Generate C++ class .h/.cpp files under Source//(Public|Private). Pair with live_coding_op compile to register. Ops: list_modules{}, create_class{name,parent_class,module?(defaults to the project's primary game module),subfolder?,header_only?,public?,base_header?,api_macro?}, list_classes{module?,subfolder?}, read_class{name,module?}. name must start with A (actors) or U (UObjects) followed by PascalCase. parent_class e.g. AActor, UObject, UActorComponent." + }, + { + "name": "build_op", + "description": "Build orchestrator — drives a full C++ rebuild cycle from outside UE. Use when Live Coding can't help (new UCLASS, new module dep, plugin changes). Ops: save_all{}, close_editor{}, build{target?,config?,platform?,project_dir?,engine_dir?}, launch_editor{}, full_rebuild{auto_reopen?:true, skip_save?, skip_close?, ...build_args}. Defaults are auto-detected: project_dir = nearest .uproject above the plugin, target = Editor, config=Development, platform=Win64, engine_dir = the project's associated/installed UE (override any via args or env UE_PROJECT_DIR/UE_BUILD_TARGET/UE_ENGINE_DIR). full_rebuild returns step-by-step log + build errors/warnings on failure." + }, + { + "name": "insights_open_trace", + "description": "Open and parse an Unreal Insights .utrace file. Runs UnrealInsights.exe headless to export all available channels into CSVs, then ingests into sqlite. Returns trace_id used by other insights_* tools. Cached by file hash — re-call is instant unless force=true. Args: trace_path (abs path to .utrace), label?, force?, project_dir?." + }, + { + "name": "insights_list_traces", + "description": "List all previously-opened traces with their trace_ids, labels and paths." + }, + { + "name": "insights_summary", + "description": "Quick overview of a trace: available tables, row counts, time span. Use to see what channels were recorded." + }, + { + "name": "insights_frame_stats", + "description": "Frame-time distribution and worst hitches. Args: trace_id, hitch_ms?(33), top_n?(20)." + }, + { + "name": "insights_top_scopes", + "description": "Top-N CPU scopes by inclusive (default) or exclusive time. Args: trace_id, thread?('game'|'render'|'gpu'|'rhi'), limit?(20), sort_by?('incl'|'excl')." + }, + { + "name": "insights_scope_detail", + "description": "Stats for one scope: call count, avg/max/min ms, worst N instances with timestamps." + }, + { + "name": "insights_gpu_top", + "description": "Top-N GPU events by total time. Requires 'gpu' channel in trace." + }, + { + "name": "insights_memory_overview", + "description": "LLM tags / memtags: top consumers by peak/current bytes. Requires 'memory' channel." + }, + { + "name": "insights_loading_top", + "description": "Slowest asset/package loads. Requires 'loadtime' or 'asset' channels." + }, + { + "name": "insights_compare_traces", + "description": "Diff two traces: frame stats deltas, top scopes that regressed or improved. Args: trace_a, trace_b." + }, + { + "name": "insights_query", + "description": "Run a read-only SELECT against the trace's sqlite cache. Use insights_summary first to see table names and columns. SELECT only — DDL/DML rejected." + }, + { + "name": "insights_render_charts", + "description": "Render SVG charts (frame timeline, histogram, per-thread breakdown, top scopes Game/Render/RHI/GPU) and bundle them into a dashboard.html. Open the returned file:// URL in any browser. No memory charts unless the trace was recorded with memory/llm channels." + }, + { + "name": "insights_report", + "description": "Generate a markdown summary across all domains (frames, scopes, GPU, memory, loading). Saves to /Saved/InsightsMCP//report.md unless save=false." + }, + { + "name": "read_python_log", + "description": "Tail the UE log file and return only Python-related lines (LogPython entries + Python tracebacks). Use to diagnose what went wrong in the editor without screenshots." + }, + { + "name": "launcher_op", + "description": "Read & edit UE Project Launcher launch profiles directly — JS-native, no Remote Control, works even when the editor is busy/crashed. Covers BOTH the Legacy Project Launcher AND the new Project Launcher (UE 5.5+): they share the same LauncherServices/ILauncherProfileManager backend and the same on-disk files, so a profile saved in either shows up here. (New-launcher 'Basic' profiles are transient/per-target and never hit disk; only saved Custom/Advanced profiles are addressable.) Profiles are *.ulp2 JSON files under /Engine/Programs/UnrealFrontend/Profiles/. Every edit writes a .bak first; delete is a soft-move to _mcp_trash (reversible). Ops: list{} (summary of all profiles), read{id|name|file} (full JSON), get{id|name|file,field}, set_field{id|name|file,field,value}, set_fields{id|name|file,fields:{...}}, set_cooked_maps{id|name|file,maps:[]}, add_cooked_map{id|name|file,map}, remove_cooked_map{id|name|file,map}, duplicate{id|name|file,new_name?,fields?} / create_profile (alias — copies an existing profile as template, fresh Id), delete{id|name|file}. Select a profile by {id} (GUID), {name} (exact, else substring), or {file} (filename). Folder override: {profiles_dir} or {engine_dir} (else env UE_PROFILES_DIR / UE_ENGINE_DIR; the engine is auto-detected from the project's association/install). CAVEAT: if the Launcher window is open it may overwrite file edits on its next save — edit with it closed, then reopen." + } +]