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>
437 lines
16 KiB
C++
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
|