Files
unreal-engine-mcp-system-pl…/Source/UEBlueprintMCPEditor/Private/SMCPWizard.cpp
Bonchellon 341e694ab5 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>
2026-06-24 01:35:53 +03:00

437 lines
16 KiB
C++

#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