#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); // UTF-8 (no BOM) so Node's JSON.parse can read it back. const bool bOk = bSer && FFileHelper::SaveStringToFile(Out, *File, FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM); 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, FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM); } #undef LOCTEXT_NAMESPACE