#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 "Engine/Engine.h" #include "Interfaces/IPluginManager.h" #include "Misc/FileHelper.h" #include "Misc/Paths.h" #include "Dom/JsonObject.h" #include "Dom/JsonValue.h" #include "Serialization/JsonReader.h" #include "Serialization/JsonSerializer.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")); FString MCPPluginDir() { TSharedPtr Plugin = IPluginManager::Get().FindPlugin(TEXT("UEBlueprintMCP")); return Plugin.IsValid() ? FPaths::ConvertRelativePathToFull(Plugin->GetBaseDir()) : FString(); } // Reads the shipped tool catalogue (tools.json). Returns name + description pairs. bool MCPReadTools(TArray>& Out) { const FString Dir = MCPPluginDir(); if (Dir.IsEmpty()) { return false; } FString Json; if (!FFileHelper::LoadFileToString(Json, *(Dir / TEXT("tools.json")))) { return false; } TArray> Items; TSharedRef> Reader = TJsonReaderFactory<>::Create(Json); if (!FJsonSerializer::Deserialize(Reader, Items)) { return false; } for (const TSharedPtr& Item : Items) { const TSharedPtr Obj = Item.IsValid() ? Item->AsObject() : nullptr; if (Obj.IsValid()) { Out.Emplace(Obj->GetStringField(TEXT("name")), Obj->GetStringField(TEXT("description"))); } } return true; } bool IsMCPZoneComment(const UEdGraphNode* Node) { const UEdGraphNode_Comment* CommentNode = Cast(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 Cursor) override { } virtual bool HandleMouseButtonDownEvent(FSlateApplication& SlateApp, const FPointerEvent& MouseEvent) override { if (!Owner.bMCPZoneModeActive || MouseEvent.GetEffectingButton() != EKeys::LeftMouseButton) { return false; } TSharedPtr Editor = Owner.ActiveMCPZoneEditor.Pin(); if (!Editor.IsValid() || !Editor->GetFocusedGraph()) { return false; } TSharedPtr 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() { // Startup check: ping and show a one-off notification. PingRc(true); } void FUEBlueprintMCPEditorModule::PingRc(bool bNotify) { const FString Url = TEXT("http://127.0.0.1:30010/remote/info"); TSharedRef Request = FHttpModule::Get().CreateRequest(); Request->SetVerb(TEXT("GET")); Request->SetURL(Url); Request->SetTimeout(3.0f); Request->OnProcessRequestComplete().BindLambda( [this, bNotify](FHttpRequestPtr Req, FHttpResponsePtr Resp, bool bSucceeded) { const bool bOk = bSucceeded && Resp.IsValid() && Resp->GetResponseCode() == 200; CachedRcStatus = bOk ? EMcpRcStatus::Up : EMcpRcStatus::Down; if (!bNotify) { return; } 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 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 FUEBlueprintMCPEditorModule::SpawnWizardTab(const FSpawnTabArgs& Args) { return SNew(SDockTab) .TabRole(ETabRole::NomadTab) [ SNew(SMCPWizard) ]; } void FUEBlueprintMCPEditorModule::RegisterMCPToolbarButton() { UToolMenu* Toolbar = UToolMenus::Get()->ExtendMenu("LevelEditor.LevelEditorToolBar.User"); if (!Toolbar) { return; } FToolMenuSection& Section = Toolbar->FindOrAddSection("MCP"); FToolMenuEntry Entry = FToolMenuEntry::InitComboButton( "MCPMenu", FUIAction(), FNewToolMenuChoice(FNewToolMenuDelegate::CreateRaw(this, &FUEBlueprintMCPEditorModule::BuildMCPStatusMenu)), LOCTEXT("MCPToolbarLabel", "MCP"), LOCTEXT("MCPToolbarTip", "UE Blueprint MCP — status, active tools and settings"), FSlateIcon(FAppStyle::GetAppStyleSetName(), "Icons.Settings")); Section.AddEntry(Entry); } void FUEBlueprintMCPEditorModule::BuildMCPStatusMenu(UToolMenu* Menu) { // Kick a fresh (silent) ping so the next open reflects current state. PingRc(false); // --- Status --- { FToolMenuSection& Sec = Menu->FindOrAddSection("MCPStatus", LOCTEXT("MCPStatusHdr", "Status")); FText StatusText; FSlateIcon StatusIcon; switch (CachedRcStatus) { case EMcpRcStatus::Up: StatusText = LOCTEXT("RcUp", "Remote Control: connected (:30010)"); StatusIcon = FSlateIcon(FAppStyle::GetAppStyleSetName(), "Icons.SuccessWithColor"); break; case EMcpRcStatus::Down: StatusText = LOCTEXT("RcDown", "Remote Control: not reachable"); StatusIcon = FSlateIcon(FAppStyle::GetAppStyleSetName(), "Icons.ErrorWithColor"); break; default: StatusText = LOCTEXT("RcUnknown", "Remote Control: checking…"); StatusIcon = FSlateIcon(FAppStyle::GetAppStyleSetName(), "Icons.WarningWithColor"); break; } Sec.AddMenuEntry("MCPRcStatus", StatusText, LOCTEXT("RcStatusTip", "Click to re-check the Remote Control bridge."), StatusIcon, FUIAction(FExecuteAction::CreateRaw(this, &FUEBlueprintMCPEditorModule::PingRc, false))); Sec.AddMenuEntry("MCPStartRC", LOCTEXT("StartRC", "Start Remote Control server"), LOCTEXT("StartRCTip", "Run WebControl.StartServer and enable it on startup."), FSlateIcon(), FUIAction(FExecuteAction::CreateLambda([this]() { if (GEngine) { GEngine->Exec(nullptr, TEXT("WebControl.EnableServerOnStartup 1")); GEngine->Exec(nullptr, TEXT("WebControl.StartServer")); } PingRc(false); }))); } // --- Tools --- { TArray> Tools; MCPReadTools(Tools); FToolMenuSection& Sec = Menu->FindOrAddSection("MCPTools", LOCTEXT("MCPToolsHdr", "Tools")); Sec.AddSubMenu("MCPToolsList", FText::Format(LOCTEXT("ActiveTools", "Active tools ({0})"), FText::AsNumber(Tools.Num())), LOCTEXT("ActiveToolsTip", "The MCP tools this server exposes to your client."), FNewToolMenuDelegate::CreateRaw(this, &FUEBlueprintMCPEditorModule::BuildToolsSubMenu), false, FSlateIcon()); } // --- Settings --- { FToolMenuSection& Sec = Menu->FindOrAddSection("MCPSettings", LOCTEXT("MCPSettingsHdr", "Settings")); Sec.AddMenuEntry("MCPOpenWizard", LOCTEXT("OpenWizardEntry", "Open Setup Wizard…"), LOCTEXT("OpenWizardEntryTip", "Remote Control, API keys and the full tool list."), FSlateIcon(FAppStyle::GetAppStyleSetName(), "Icons.Settings"), FUIAction(FExecuteAction::CreateLambda([]() { FGlobalTabmanager::Get()->TryInvokeTab(MCPWizardTabId); }))); } } void FUEBlueprintMCPEditorModule::BuildToolsSubMenu(UToolMenu* Menu) { TArray> Tools; if (!MCPReadTools(Tools) || Tools.Num() == 0) { FToolMenuSection& Sec = Menu->FindOrAddSection("MCPToolsNone"); Sec.AddMenuEntry("MCPNoTools", LOCTEXT("NoTools", "tools.json not found"), LOCTEXT("NoToolsTip", "Run `npm run dump-tools` in the plugin folder to generate the catalogue."), FSlateIcon(), FUIAction(FExecuteAction::CreateLambda([]() {}), FCanExecuteAction::CreateLambda([]() { return false; }))); return; } FToolMenuSection& Sec = Menu->FindOrAddSection("MCPToolsItems"); for (const TPair& Tool : Tools) { // First line of the description as the tooltip — keeps it readable. FString Tip = Tool.Value; int32 Dot; if (Tip.FindChar(TEXT('.'), Dot) && Dot > 20) { Tip = Tip.Left(Dot + 1); } Sec.AddMenuEntry(FName(*Tool.Key), FText::FromString(Tool.Key), FText::FromString(Tip), FSlateIcon(), FUIAction(FExecuteAction::CreateLambda([]() {}))); } } void FUEBlueprintMCPEditorModule::RegisterMenus() { FToolMenuOwnerScoped OwnerScoped(this); RegisterWizardTabAndMenu(); RegisterMCPToolbarButton(); 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(); if (!Context || !Context->BlueprintEditor.IsValid() || !Context->GetBlueprintObj()) { return; } TWeakPtr 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 BlueprintEditor) { TSharedPtr 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 PreviousEditor = ActiveMCPZoneEditor.Pin()) { RemoveMCPZones(PreviousEditor->GetBlueprintObj()); } } bMCPZoneModeActive = true; ActiveMCPZoneEditor = Editor; if (!MCPZoneInputProcessor.IsValid() && FSlateApplication::IsInitialized()) { MCPZoneInputProcessor = MakeShared(*this); FSlateApplication::Get().RegisterInputPreProcessor(MCPZoneInputProcessor); } } bool FUEBlueprintMCPEditorModule::IsMCPZoneModeActive(TWeakPtr BlueprintEditor) const { return bMCPZoneModeActive && ActiveMCPZoneEditor.Pin() == BlueprintEditor.Pin(); } bool FUEBlueprintMCPEditorModule::TryCreateMCPZoneFromScreenDrag(const FVector2f& StartScreenPosition, const FVector2f& EndScreenPosition) { TSharedPtr Editor = ActiveMCPZoneEditor.Pin(); if (!Editor.IsValid()) { return false; } UBlueprint* Blueprint = Editor->GetBlueprintObj(); UEdGraph* Graph = Editor->GetFocusedGraph(); if (!Blueprint || !Graph) { return false; } TSharedPtr 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(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 Graphs; Blueprint->GetAllGraphs(Graphs); for (UEdGraph* Graph : Graphs) { if (!Graph) { continue; } Graph->Modify(); TArray Nodes = Graph->Nodes; for (UEdGraphNode* Node : Nodes) { if (IsMCPZoneComment(Node)) { Graph->RemoveNode(Node); } } } FBlueprintEditorUtils::MarkBlueprintAsModified(Blueprint); } #undef LOCTEXT_NAMESPACE