Add in-editor Slate setup wizard (Tools > MCP Setup Wizard)

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 <noreply@anthropic.com>
This commit is contained in:
Bonchellon
2026-06-24 01:35:53 +03:00
parent b04ba768b4
commit 341e694ab5
10 changed files with 799 additions and 12 deletions

View File

@ -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

View File

@ -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<SWidget> MakeSection(const FText& Title, const FText& Subtitle, TSharedRef<SWidget> 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<SMCPWizard> WeakSelf = StaticCastSharedRef<SMCPWizard>(AsShared());
TSharedRef<IHttpRequest, ESPMode::ThreadSafe> 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<SMCPWizard> 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<FJsonObject> Root = MakeShared<FJsonObject>();
FString Existing;
if (FFileHelper::LoadFileToString(Existing, *File))
{
TSharedRef<TJsonReader<>> Reader = TJsonReaderFactory<>::Create(Existing);
TSharedPtr<FJsonObject> Parsed;
if (FJsonSerializer::Deserialize(Reader, Parsed) && Parsed.IsValid())
{
Root = Parsed;
}
}
TSharedPtr<FJsonObject> Eleven = Root->HasTypedField<EJson::Object>(TEXT("elevenlabs"))
? Root->GetObjectField(TEXT("elevenlabs"))
: MakeShared<FJsonObject>();
Eleven->SetStringField(TEXT("apiKey"), Value);
Root->SetObjectField(TEXT("elevenlabs"), Eleven);
FString Out;
TSharedRef<TJsonWriter<TCHAR, TPrettyJsonPrintPolicy<TCHAR>>> Writer = TJsonWriterFactory<TCHAR, TPrettyJsonPrintPolicy<TCHAR>>::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<FString> 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<TJsonReader<>> Reader = TJsonReaderFactory<>::Create(Json);
TSharedPtr<FJsonObject> Root;
if (FJsonSerializer::Deserialize(Reader, Root) && Root.IsValid() && Root->HasTypedField<EJson::Object>(TEXT("elevenlabs")))
{
const FString Key = Root->GetObjectField(TEXT("elevenlabs"))->GetStringField(TEXT("apiKey"));
ElevenLabsBox->SetText(FText::FromString(Key));
}
}
}
// ---------------------------------------------------------------------------
// Tools
// ---------------------------------------------------------------------------
TSharedRef<SWidget> 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<TSharedPtr<FJsonValue>> Items;
TSharedRef<TJsonReader<>> Reader = TJsonReaderFactory<>::Create(Json);
if (!FJsonSerializer::Deserialize(Reader, Items))
{
return SNew(STextBlock).Text(LOCTEXT("BadTools", "tools.json could not be parsed."));
}
TSharedRef<SVerticalBox> List = SNew(SVerticalBox);
for (const TSharedPtr<FJsonValue>& Item : Items)
{
const TSharedPtr<FJsonObject> 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<IPlugin> 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<SNotificationItem> 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<FString> 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

View File

@ -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<SEditableTextBox> AnthropicBox;
TSharedPtr<SEditableTextBox> ElevenLabsBox;
FReply OnSaveAnthropicToken();
FReply OnSaveElevenLabsKey();
void LoadExistingKeys();
// --- Tools ---
TSharedRef<SWidget> 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);
};

View File

@ -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<SDockTab> 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");

View File

@ -18,6 +18,8 @@ private:
friend class FMCPZoneInputProcessor;
void RegisterMenus();
void RegisterWizardTabAndMenu();
TSharedRef<class SDockTab> SpawnWizardTab(const class FSpawnTabArgs& Args);
void NotifyBridgeStatus();
void ToggleMCPZoneMode(TWeakPtr<FBlueprintEditor> BlueprintEditor);
bool IsMCPZoneModeActive(TWeakPtr<FBlueprintEditor> BlueprintEditor) const;
@ -26,6 +28,7 @@ private:
void RemoveMCPZones(UBlueprint* Blueprint) const;
bool bMCPZoneModeActive = false;
bool bWizardTabRegistered = false;
TWeakPtr<FBlueprintEditor> ActiveMCPZoneEditor;
TSharedPtr<IInputProcessor> MCPZoneInputProcessor;
};

View File

@ -31,6 +31,7 @@ public class UEBlueprintMCPEditor : ModuleRules
"EditorSubsystem",
"ToolMenus",
"AssetTools",
"Projects",
"Json",
"JsonUtilities",
"AssetRegistry",

View File

@ -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": {

18
src/dumpTools.mjs Normal file
View File

@ -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}`);

View File

@ -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,6 +1350,10 @@ 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.
// 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 });
@ -1358,3 +1367,4 @@ if (PP.isFirstRun()) {
}
await server.connect(new StdioServerTransport());
}

202
tools.json Normal file
View File

@ -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 <Project>/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/<module>/(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 = <ProjectName>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 <project>/Saved/InsightsMCP/<trace_id>/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 <EngineDir>/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."
}
]