Files
unreal-engine-mcp-system-pl…/Source/UEBlueprintMCPEditor/Private/UEBlueprintMCPEditor.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

402 lines
14 KiB
C++

#include "UEBlueprintMCPEditor.h"
#include "HttpModule.h"
#include "Interfaces/IHttpResponse.h"
#include "Misc/CoreDelegates.h"
#include "Framework/Notifications/NotificationManager.h"
#include "Widgets/Notifications/SNotificationList.h"
#include "BlueprintEditor.h"
#include "BlueprintEditorContext.h"
#include "EdGraph/EdGraph.h"
#include "EdGraph/EdGraphNode.h"
#include "EdGraphNode_Comment.h"
#include "Framework/Application/IInputProcessor.h"
#include "Framework/Application/SlateApplication.h"
#include "Framework/Commands/UIAction.h"
#include "Kismet2/BlueprintEditorUtils.h"
#include "ScopedTransaction.h"
#include "SGraphPanel.h"
#include "ToolMenu.h"
#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"
namespace
{
const TCHAR* MCPZoneCommentText = TEXT("MCP Zone");
const TCHAR* LegacyMCPZoneCommentText = TEXT("MCP Draft Zone");
const FName MCPWizardTabId(TEXT("MCPSetupWizard"));
bool IsMCPZoneComment(const UEdGraphNode* Node)
{
const UEdGraphNode_Comment* CommentNode = Cast<UEdGraphNode_Comment>(Node);
return CommentNode
&& (CommentNode->NodeComment.Equals(MCPZoneCommentText, ESearchCase::IgnoreCase)
|| CommentNode->NodeComment.Equals(LegacyMCPZoneCommentText, ESearchCase::IgnoreCase));
}
}
class FMCPZoneInputProcessor : public IInputProcessor
{
public:
explicit FMCPZoneInputProcessor(FUEBlueprintMCPEditorModule& InOwner)
: Owner(InOwner)
{
}
virtual void Tick(const float DeltaTime, FSlateApplication& SlateApp, TSharedRef<ICursor> Cursor) override
{
}
virtual bool HandleMouseButtonDownEvent(FSlateApplication& SlateApp, const FPointerEvent& MouseEvent) override
{
if (!Owner.bMCPZoneModeActive || MouseEvent.GetEffectingButton() != EKeys::LeftMouseButton)
{
return false;
}
TSharedPtr<FBlueprintEditor> Editor = Owner.ActiveMCPZoneEditor.Pin();
if (!Editor.IsValid() || !Editor->GetFocusedGraph())
{
return false;
}
TSharedPtr<SGraphEditor> GraphEditor = Editor->OpenGraphAndBringToFront(Editor->GetFocusedGraph(), false);
SGraphPanel* GraphPanel = GraphEditor.IsValid() ? GraphEditor->GetGraphPanel() : nullptr;
if (!GraphPanel)
{
return false;
}
const FGeometry& Geometry = GraphPanel->GetTickSpaceGeometry();
const FVector2f ScreenPosition = UE::Slate::CastToVector2f(MouseEvent.GetScreenSpacePosition());
if (!Geometry.IsUnderLocation(ScreenPosition))
{
return false;
}
bDragging = true;
StartScreenPosition = ScreenPosition;
return true;
}
virtual bool HandleMouseMoveEvent(FSlateApplication& SlateApp, const FPointerEvent& MouseEvent) override
{
return bDragging && Owner.bMCPZoneModeActive;
}
virtual bool HandleMouseButtonUpEvent(FSlateApplication& SlateApp, const FPointerEvent& MouseEvent) override
{
if (!bDragging || MouseEvent.GetEffectingButton() != EKeys::LeftMouseButton)
{
return false;
}
bDragging = false;
const FVector2f EndScreenPosition = UE::Slate::CastToVector2f(MouseEvent.GetScreenSpacePosition());
Owner.TryCreateMCPZoneFromScreenDrag(StartScreenPosition, EndScreenPosition);
return true;
}
virtual const TCHAR* GetDebugName() const override
{
return TEXT("MCPZoneInputProcessor");
}
private:
FUEBlueprintMCPEditorModule& Owner;
bool bDragging = false;
FVector2f StartScreenPosition = FVector2f::ZeroVector;
};
IMPLEMENT_MODULE(FUEBlueprintMCPEditorModule, UEBlueprintMCPEditor)
void FUEBlueprintMCPEditorModule::StartupModule()
{
UToolMenus::RegisterStartupCallback(
FSimpleMulticastDelegate::FDelegate::CreateRaw(this, &FUEBlueprintMCPEditorModule::RegisterMenus));
FCoreDelegates::OnPostEngineInit.AddRaw(this, &FUEBlueprintMCPEditorModule::NotifyBridgeStatus);
}
void FUEBlueprintMCPEditorModule::NotifyBridgeStatus()
{
const FString Url = TEXT("http://127.0.0.1:30010/remote/info");
TSharedRef<IHttpRequest> Request = FHttpModule::Get().CreateRequest();
Request->SetVerb(TEXT("GET"));
Request->SetURL(Url);
Request->SetTimeout(3.0f);
Request->OnProcessRequestComplete().BindLambda(
[](FHttpRequestPtr Req, FHttpResponsePtr Resp, bool bSucceeded)
{
const bool bOk = bSucceeded && Resp.IsValid() && Resp->GetResponseCode() == 200;
FNotificationInfo Info(bOk
? LOCTEXT("MCPBridgeUp", "UE Blueprint MCP bridge ready (Remote Control :30010)")
: LOCTEXT("MCPBridgeDown", "UE Blueprint MCP bridge NOT reachable on :30010 — check Remote Control plugin"));
Info.ExpireDuration = bOk ? 4.0f : 8.0f;
Info.bUseSuccessFailIcons = true;
Info.bFireAndForget = true;
TSharedPtr<SNotificationItem> Item = FSlateNotificationManager::Get().AddNotification(Info);
if (Item.IsValid())
{
Item->SetCompletionState(bOk ? SNotificationItem::CS_Success : SNotificationItem::CS_Fail);
}
});
Request->ProcessRequest();
}
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);
MCPZoneInputProcessor.Reset();
}
if (UToolMenus::IsToolMenuUIEnabled())
{
UToolMenus::UnRegisterStartupCallback(this);
UToolMenus::UnregisterOwner(this);
}
}
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");
Section.AddDynamicEntry("MCPDraftZoneCommands", FNewToolMenuSectionDelegate::CreateLambda([this](FToolMenuSection& InSection)
{
UBlueprintEditorToolMenuContext* Context = InSection.FindContext<UBlueprintEditorToolMenuContext>();
if (!Context || !Context->BlueprintEditor.IsValid() || !Context->GetBlueprintObj())
{
return;
}
TWeakPtr<FBlueprintEditor> WeakEditor = Context->BlueprintEditor;
FUIAction Action(
FExecuteAction::CreateLambda([this, WeakEditor]()
{
ToggleMCPZoneMode(WeakEditor);
}),
FCanExecuteAction::CreateLambda([WeakEditor]()
{
return WeakEditor.IsValid() && WeakEditor.Pin()->GetFocusedGraph() != nullptr;
}),
FIsActionChecked::CreateLambda([this, WeakEditor]()
{
return IsMCPZoneModeActive(WeakEditor);
}));
FToolMenuEntry Entry = FToolMenuEntry::InitToolBarButton(
FName(TEXT("MCPDraftZone")),
FToolUIActionChoice(Action),
LOCTEXT("MCPDraftZone_Label", "MCP Zone"),
LOCTEXT("MCPDraftZone_Tooltip", "Toggle MCP Zone mode. While active, left-mouse drag in the graph to create a generation zone. Toggle off to remove MCP Zone comments while keeping generated nodes."),
FSlateIcon(),
EUserInterfaceActionType::ToggleButton);
InSection.AddEntry(Entry);
}));
}
void FUEBlueprintMCPEditorModule::ToggleMCPZoneMode(TWeakPtr<FBlueprintEditor> BlueprintEditor)
{
TSharedPtr<FBlueprintEditor> Editor = BlueprintEditor.Pin();
if (!Editor.IsValid())
{
return;
}
if (bMCPZoneModeActive && ActiveMCPZoneEditor.Pin() == Editor)
{
RemoveMCPZones(Editor->GetBlueprintObj());
bMCPZoneModeActive = false;
ActiveMCPZoneEditor.Reset();
if (MCPZoneInputProcessor.IsValid() && FSlateApplication::IsInitialized())
{
FSlateApplication::Get().UnregisterInputPreProcessor(MCPZoneInputProcessor);
MCPZoneInputProcessor.Reset();
}
return;
}
if (bMCPZoneModeActive)
{
if (TSharedPtr<FBlueprintEditor> PreviousEditor = ActiveMCPZoneEditor.Pin())
{
RemoveMCPZones(PreviousEditor->GetBlueprintObj());
}
}
bMCPZoneModeActive = true;
ActiveMCPZoneEditor = Editor;
if (!MCPZoneInputProcessor.IsValid() && FSlateApplication::IsInitialized())
{
MCPZoneInputProcessor = MakeShared<FMCPZoneInputProcessor>(*this);
FSlateApplication::Get().RegisterInputPreProcessor(MCPZoneInputProcessor);
}
}
bool FUEBlueprintMCPEditorModule::IsMCPZoneModeActive(TWeakPtr<FBlueprintEditor> BlueprintEditor) const
{
return bMCPZoneModeActive && ActiveMCPZoneEditor.Pin() == BlueprintEditor.Pin();
}
bool FUEBlueprintMCPEditorModule::TryCreateMCPZoneFromScreenDrag(const FVector2f& StartScreenPosition, const FVector2f& EndScreenPosition)
{
TSharedPtr<FBlueprintEditor> Editor = ActiveMCPZoneEditor.Pin();
if (!Editor.IsValid())
{
return false;
}
UBlueprint* Blueprint = Editor->GetBlueprintObj();
UEdGraph* Graph = Editor->GetFocusedGraph();
if (!Blueprint || !Graph)
{
return false;
}
TSharedPtr<SGraphEditor> GraphEditor = Editor->OpenGraphAndBringToFront(Graph, false);
SGraphPanel* GraphPanel = GraphEditor.IsValid() ? GraphEditor->GetGraphPanel() : nullptr;
if (!GraphPanel)
{
return false;
}
const FGeometry& Geometry = GraphPanel->GetTickSpaceGeometry();
const FVector2f LocalStart = UE::Slate::CastToVector2f(Geometry.AbsoluteToLocal(StartScreenPosition));
const FVector2f LocalEnd = UE::Slate::CastToVector2f(Geometry.AbsoluteToLocal(EndScreenPosition));
const FVector2f GraphStart = UE::Slate::CastToVector2f(GraphPanel->PanelCoordToGraphCoord(LocalStart));
const FVector2f GraphEnd = UE::Slate::CastToVector2f(GraphPanel->PanelCoordToGraphCoord(LocalEnd));
CreateMCPZone(Graph, Blueprint, GraphStart, GraphEnd);
return true;
}
void FUEBlueprintMCPEditorModule::CreateMCPZone(UEdGraph* Graph, UBlueprint* Blueprint, const FVector2f& GraphStart, const FVector2f& GraphEnd) const
{
if (!Blueprint || !Graph)
{
return;
}
const FScopedTransaction Transaction(LOCTEXT("CreateMCPDraftZone", "Create MCP Draft Zone"));
Graph->Modify();
Blueprint->Modify();
constexpr int32 MinWidth = 240;
constexpr int32 MinHeight = 160;
const int32 MinX = FMath::FloorToInt(FMath::Min(GraphStart.X, GraphEnd.X));
const int32 MinY = FMath::FloorToInt(FMath::Min(GraphStart.Y, GraphEnd.Y));
const int32 MaxX = FMath::CeilToInt(FMath::Max(GraphStart.X, GraphEnd.X));
const int32 MaxY = FMath::CeilToInt(FMath::Max(GraphStart.Y, GraphEnd.Y));
UEdGraphNode_Comment* ZoneNode = NewObject<UEdGraphNode_Comment>(Graph);
ZoneNode->CreateNewGuid();
ZoneNode->NodeComment = MCPZoneCommentText;
ZoneNode->bCommentBubbleVisible = false;
ZoneNode->CommentColor = FLinearColor(0.05f, 0.32f, 0.85f, 0.35f);
ZoneNode->NodePosX = MinX;
ZoneNode->NodePosY = MinY;
ZoneNode->NodeWidth = FMath::Max(MinWidth, MaxX - MinX);
ZoneNode->NodeHeight = FMath::Max(MinHeight, MaxY - MinY);
Graph->AddNode(ZoneNode, true, false);
ZoneNode->PostPlacedNewNode();
FBlueprintEditorUtils::MarkBlueprintAsModified(Blueprint);
}
void FUEBlueprintMCPEditorModule::RemoveMCPZones(UBlueprint* Blueprint) const
{
if (!Blueprint)
{
return;
}
const FScopedTransaction Transaction(LOCTEXT("RemoveMCPDraftZones", "Remove MCP Draft Zones"));
Blueprint->Modify();
TArray<UEdGraph*> Graphs;
Blueprint->GetAllGraphs(Graphs);
for (UEdGraph* Graph : Graphs)
{
if (!Graph)
{
continue;
}
Graph->Modify();
TArray<UEdGraphNode*> Nodes = Graph->Nodes;
for (UEdGraphNode* Node : Nodes)
{
if (IsMCPZoneComment(Node))
{
Graph->RemoveNode(Node);
}
}
}
FBlueprintEditorUtils::MarkBlueprintAsModified(Blueprint);
}
#undef LOCTEXT_NAMESPACE