diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/Handlers/UMCPHandler_ShowCommands.h b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/Handlers/UMCPHandler_ShowCommands.h index d87aa027..9ec5ea22 100644 --- a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/Handlers/UMCPHandler_ShowCommands.h +++ b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/Handlers/UMCPHandler_ShowCommands.h @@ -2,21 +2,9 @@ #include "CoreMinimal.h" #include "MCPHandler.h" -#include "MCPAssetFinder.h" #include "MCPUtils.h" -#include "Engine/Blueprint.h" -#include "EdGraph/EdGraph.h" -#include "EdGraph/EdGraphNode.h" -#include "EdGraph/EdGraphPin.h" -#include "EdGraphSchema_K2.h" -#include "UObject/UObjectIterator.h" #include "UMCPHandler_ShowCommands.generated.h" - -// --------------------------------------------------------------------------- -// --------------------------------------------------------------------------- -// --------------------------------------------------------------------------- - UCLASS() class UMCPHandler_ShowCommands : public UObject, public IMCPHandler { @@ -31,49 +19,28 @@ public: return TEXT("List all available commands with their descriptions."); } - // Collect all handler classes sorted by tool name. - TArray> CollectHandlers() const - { - TArray> Handlers; - for (TObjectIterator It; It; ++It) - { - UClass* Class = *It; - if (Class->HasAnyClassFlags(CLASS_Abstract)) continue; - const IMCPHandler* Handler = Cast(Class->GetDefaultObject()); - if (!Handler) continue; - FString ToolName = MCPUtils::GetToolName(Class); - Handlers.Add({ToolName, Class}); - } - Handlers.Sort(); - return Handlers; - } - virtual void Handle(const FJsonObject* Json, FStringBuilderBase& Result) override { - auto Handlers = CollectHandlers(); - Result.Appendf(TEXT("%d commands:\n"), Handlers.Num()); - - for (const auto& Pair : Handlers) + for (UClass* Class : MCPUtils::CollectHandlerClasses()) { if (Verbose) { - MCPUtils::FormatCommandHelp(Pair.Value, Result); - continue; + MCPUtils::FormatCommandHelp(Class, Result); } - - // Non-verbose: just the signature line - UClass* Class = Pair.Value; - Result.Append(Pair.Key); - Result.Append(TEXT("(")); - bool bFirst = true; - for (TFieldIterator PropIt(Class, EFieldIterationFlags::None); PropIt; ++PropIt) + else { - if (!bFirst) Result.Append(TEXT(",")); - bFirst = false; - if (PropIt->HasMetaData(TEXT("Optional"))) Result.Append(TEXT("?")); - Result.Append(MCPUtils::PropertyNameToJsonKey(PropIt->GetName())); + Result.Append(MCPUtils::GetToolName(Class)); + Result.Append(TEXT("(")); + bool bFirst = true; + for (TFieldIterator PropIt(Class, EFieldIterationFlags::None); PropIt; ++PropIt) + { + if (!bFirst) Result.Append(TEXT(",")); + bFirst = false; + if (PropIt->HasMetaData(TEXT("Optional"))) Result.Append(TEXT("?")); + Result.Append(MCPUtils::PropertyNameToJsonKey(PropIt->GetName())); + } + Result.Append(TEXT(")\n")); } - Result.Append(TEXT(")\n")); } } }; diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPCommandlet.cpp b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPCommandlet.cpp index 71587a08..33ee0e24 100644 --- a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPCommandlet.cpp +++ b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPCommandlet.cpp @@ -12,50 +12,26 @@ UBlueprintMCPCommandlet::UBlueprintMCPCommandlet() int32 UBlueprintMCPCommandlet::Main(const FString& Params) { - // Parse port from command-line params - TArray Tokens; - TArray Switches; - TMap ParamMap; - ParseCommandLine(*Params, Tokens, Switches, ParamMap); - - int32 Port = 9847; - if (ParamMap.Contains(TEXT("port"))) - { - Port = FCString::Atoi(*ParamMap[TEXT("port")]); - } - - // Create and start the shared server - Server = MakeUnique(); - if (!Server->Start(Port)) + // The UMCPServer editor subsystem starts the server automatically. + // We just need to tick it, since FTickableEditorObject doesn't tick in commandlet mode. + UMCPServer* Server = UMCPServer::Get(); + if (!Server) { + UE_LOG(LogTemp, Error, TEXT("BlueprintMCP: Could not find MCP server subsystem")); return 1; } - // Main loop — tick the engine systems and process queued requests one at a time double LastTime = FPlatformTime::Seconds(); - auto TickEngine = [&LastTime]() + while (!IsEngineExitRequested()) { double CurrentTime = FPlatformTime::Seconds(); double DeltaTime = CurrentTime - LastTime; LastTime = CurrentTime; FTSTicker::GetCoreTicker().Tick(DeltaTime); - }; - - while (!IsEngineExitRequested()) - { - TickEngine(); - - if (Server->ProcessOneRequest()) - { - // Tick again immediately after completing a request so pending - // HTTP responses get flushed. - TickEngine(); - } - + Server->Tick(DeltaTime); FPlatformProcess::Sleep(0.01f); } - Server->Stop(); return 0; } diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPEditorSubsystem.cpp b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPEditorSubsystem.cpp index e6a1bfc3..8f4c5c9b 100644 --- a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPEditorSubsystem.cpp +++ b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPEditorSubsystem.cpp @@ -1,5 +1,4 @@ #include "MCPEditorSubsystem.h" -#include "MCPServer.h" #include "BlueprintExporter.h" #include "Engine/Blueprint.h" #include "EdGraph/EdGraph.h" @@ -11,59 +10,16 @@ void UBlueprintMCPEditorSubsystem::Initialize(FSubsystemCollectionBase& Collecti { Super::Initialize(Collection); - // Don't start in commandlet mode — the commandlet has its own server instance. - if (IsRunningCommandlet()) - { - return; - } - OnAssetSavedHandle = UPackage::PackageSavedWithContextEvent.AddUObject( this, &UBlueprintMCPEditorSubsystem::OnAssetSaved); - - Server = MakeUnique(); - if (Server->Start(9847, /*bEditorMode=*/true)) - { - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Editor subsystem started — MCP server on port %d"), Server->GetPort()); - } - else - { - UE_LOG(LogTemp, Warning, TEXT("BlueprintMCP: Editor subsystem failed to start MCP server (port may be in use)")); - Server.Reset(); - } } void UBlueprintMCPEditorSubsystem::Deinitialize() { UPackage::PackageSavedWithContextEvent.Remove(OnAssetSavedHandle); - - if (Server) - { - Server->Stop(); - Server.Reset(); - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Editor subsystem stopped.")); - } - Super::Deinitialize(); } -void UBlueprintMCPEditorSubsystem::Tick(float DeltaTime) -{ - if (Server) - { - Server->ProcessOneRequest(); - } -} - -bool UBlueprintMCPEditorSubsystem::IsTickable() const -{ - return Server.IsValid() && Server->IsRunning(); -} - -TStatId UBlueprintMCPEditorSubsystem::GetStatId() const -{ - RETURN_QUICK_DECLARE_CYCLE_STAT(UBlueprintMCPEditorSubsystem, STATGROUP_Tickables); -} - void UBlueprintMCPEditorSubsystem::OnAssetSaved(const FString& PackageFilename, UPackage* Package, FObjectPostSaveContext Context) { if (!Package) return; diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPServer.cpp b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPServer.cpp index 22504650..6158427d 100644 --- a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPServer.cpp +++ b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPServer.cpp @@ -103,90 +103,19 @@ // Get() — retrieve the active server via the editor subsystem // ============================================================ -#include "MCPEditorSubsystem.h" - -FMCPServer* FMCPServer::Get() +UMCPServer* UMCPServer::Get() { if (!GEditor) return nullptr; - auto* Sub = GEditor->GetEditorSubsystem(); - return Sub ? Sub->GetServer() : nullptr; -} - -FString FMCPServer::DispatchToolCall(const FString& ToolName, const FJsonObject* Params) -{ - UClass** HandlerClass = MCPHandlerRegistry.Find(ToolName); - if (!HandlerClass) - { - return FString::Printf(TEXT("Unknown tool: %s"), *ToolName); - } - - TStrongObjectPtr HandlerObj(NewObject(GetTransientPackage(), *HandlerClass)); - IMCPHandler* Handler = Cast(HandlerObj.Get()); - - TStringBuilder<4096> PopulateError; - if (!MCPUtils::PopulateFromJson(HandlerObj->GetClass(), HandlerObj.Get(), Params, PopulateError)) - { - PopulateError.Append(TEXT("\nUsage:\n")); - MCPUtils::FormatCommandHelp(*HandlerClass, PopulateError); - return PopulateError.ToString(); - } - - // Try text handler first; fall back to JSON if nothing was written. - TStringBuilder<32768> TextResult; - Handler->Handle(Params, TextResult); - if (TextResult.Len() > 0) - { - FString Result = TextResult.ToString(); - for (int32 i = 0; i < Result.Len(); ++i) - { - if (Result[i] == TEXT('\0')) Result[i] = TEXT(' '); - } - return Result; - } - - // Invoke the Json handler. - TSharedRef JsonResult = MakeShared(); - Handler->Handle(Params, &*JsonResult); - return MCPUtils::JsonToString(JsonResult); + return GEditor->GetEditorSubsystem(); } // ============================================================ -// HandleRequest — parse a JSON command and dispatch +// Initialization and Shutdown // ============================================================ -FString FMCPServer::HandleRequest(const FString& Line) +void UMCPServer::Initialize(FSubsystemCollectionBase& Collection) { - TSharedPtr Request = MCPUtils::ParseBodyJson(Line); - if (!Request.IsValid()) - { - TSharedRef ErrResult = MakeShared(); - MCPUtils::MakeErrorJson(&*ErrResult, TEXT("JSON parse error")); - return MCPUtils::JsonToString(ErrResult); - } - - FString Command; - if (!Request->TryGetStringField(TEXT("command"), Command)) - { - TSharedRef ErrResult = MakeShared(); - MCPUtils::MakeErrorJson(&*ErrResult, TEXT("Missing 'command' field")); - return MCPUtils::JsonToString(ErrResult); - } - Request->RemoveField(TEXT("command")); - - return DispatchToolCall(Command, Request.Get()); -} - -// ============================================================ -// TCP Server: Start / Stop / ProcessOneRequest -// ============================================================ - -bool FMCPServer::Start(int32 InPort, bool bEditorMode) -{ - Port = InPort; - bIsEditor = bEditorMode; - - // Register handlers - BuildMCPHandlerRegistry(); + Super::Initialize(Collection); // Create TCP listen socket ISocketSubsystem* SocketSub = ISocketSubsystem::Get(PLATFORM_SOCKETSUBSYSTEM); @@ -194,7 +123,7 @@ bool FMCPServer::Start(int32 InPort, bool bEditorMode) if (!ListenSocket) { UE_LOG(LogTemp, Error, TEXT("BlueprintMCP: Failed to create listen socket")); - return false; + return; } ListenSocket->SetReuseAddr(true); @@ -210,7 +139,7 @@ bool FMCPServer::Start(int32 InPort, bool bEditorMode) UE_LOG(LogTemp, Error, TEXT("BlueprintMCP: Failed to bind to port %d"), Port); SocketSub->DestroySocket(ListenSocket); ListenSocket = nullptr; - return false; + return; } if (!ListenSocket->Listen(4)) @@ -218,17 +147,21 @@ bool FMCPServer::Start(int32 InPort, bool bEditorMode) UE_LOG(LogTemp, Error, TEXT("BlueprintMCP: Failed to listen on port %d"), Port); SocketSub->DestroySocket(ListenSocket); ListenSocket = nullptr; - return false; + return; } + BuildMCPHandlerRegistry(); bRunning = true; UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: MCP server listening on tcp://localhost:%d"), Port); - return true; } -void FMCPServer::Stop() +void UMCPServer::Deinitialize() { - if (!bRunning) return; + if (!bRunning) + { + Super::Deinitialize(); + return; + } ISocketSubsystem* SocketSub = ISocketSubsystem::Get(PLATFORM_SOCKETSUBSYSTEM); @@ -274,9 +207,117 @@ void FMCPServer::Stop() bRunning = false; bShuttingDown = false; UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Server stopped.")); + Super::Deinitialize(); } -void FMCPServer::AcceptNewConnections() +// ============================================================ +// FTickableEditorObject interface +// ============================================================ + +void UMCPServer::Tick(float DeltaTime) +{ + // Accept new connections (non-blocking) + AcceptNewConnections(); + + // Clean up finished client threads + CleanupFinishedClients(); + + // Dequeue one pending message + TSharedPtr Request; + { + FScopeLock Lock(&Mutex); + if (PendingMessages.Num() > 0) + { + Request = PendingMessages[0]; + PendingMessages.RemoveAt(0); + } + } + + // If we have a request, process it. + if (Request.IsValid()) + { + FString Response = HandleRequest(Request->Line); + Request->Response.SetValue(Response); + } +} + +bool UMCPServer::IsTickable() const +{ + return bRunning; +} + +TStatId UMCPServer::GetStatId() const +{ + RETURN_QUICK_DECLARE_CYCLE_STAT(UMCPServer, STATGROUP_Tickables); +} + +// ============================================================ +// HandleRequest — Given a command, execute it. +// ============================================================ + +FString UMCPServer::HandleRequest(const FString& Line) +{ + // Turn the request string into a JSON tree. + TSharedPtr Request = MCPUtils::ParseBodyJson(Line); + if (!Request.IsValid()) + { + return TEXT("Request is not valid JSON"); + } + + // Extract the command from the request. + FString Command; + if (!Request->TryGetStringField(TEXT("command"), Command)) + { + return TEXT("Request does not contain 'command' parameter"); + } + Request->RemoveField(TEXT("command")); + + // Find the handler UClass for the specified command. + UClass** HandlerClass = MCPHandlerRegistry.Find(Command); + if (!HandlerClass) + { + return FString::Printf(TEXT("Unknown command: %s"), *Command); + } + + // Make an object of the handler class. + TStrongObjectPtr HandlerObj(NewObject(GetTransientPackage(), *HandlerClass)); + IMCPHandler* Handler = Cast(HandlerObj.Get()); + + // Populate the handler object with the request parameters. + TStringBuilder<4096> PopulateError; + if (!MCPUtils::PopulateFromJson(HandlerObj->GetClass(), HandlerObj.Get(), &*Request, PopulateError)) + { + PopulateError.Append(TEXT("\nUsage:\n")); + MCPUtils::FormatCommandHelp(*HandlerClass, PopulateError); + return PopulateError.ToString(); + } + + // Call the text handler. This may be a no-op, in which case + // the string builder will remain empty. That indicates that + // the json handler should be used instead. + TStringBuilder<32768> TextResult; + Handler->Handle(&*Request, TextResult); + if (TextResult.Len() > 0) + { + FString Result = TextResult.ToString(); + for (int32 i = 0; i < Result.Len(); ++i) + { + if (Result[i] == TEXT('\0')) Result[i] = TEXT(' '); + } + return Result; + } + + // Invoke the Json handler. + TSharedRef JsonResult = MakeShared(); + Handler->Handle(&*Request, &*JsonResult); + return MCPUtils::JsonToString(JsonResult); +} + +// ============================================================ +// Connection Maintenance +// ============================================================ + +void UMCPServer::AcceptNewConnections() { if (!ListenSocket) return; @@ -296,7 +337,7 @@ void FMCPServer::AcceptNewConnections() UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Client connected.")); } -void FMCPServer::CleanupFinishedClients() +void UMCPServer::CleanupFinishedClients() { ISocketSubsystem* SocketSub = ISocketSubsystem::Get(PLATFORM_SOCKETSUBSYSTEM); @@ -313,7 +354,7 @@ void FMCPServer::CleanupFinishedClients() } } -void FMCPServer::ClientThreadFunc(FMCPServer* Server, TSharedPtr Client) +void UMCPServer::ClientThreadFunc(UMCPServer* Server, TSharedPtr Client) { FSocket* Socket = Client->Socket; FString LineBuffer; @@ -344,7 +385,7 @@ void FMCPServer::ClientThreadFunc(FMCPServer* Server, TSharedPtr Msg = MakeShared(); + TSharedPtr Msg = MakeShared(); Msg->Line = Line; TFuture Future = Msg->Response.GetFuture(); @@ -375,62 +416,15 @@ void FMCPServer::ClientThreadFunc(FMCPServer* Server, TSharedPtr Msg; - { - FScopeLock Lock(&Mutex); - if (PendingMessages.Num() > 0) - { - Msg = PendingMessages[0]; - PendingMessages.RemoveAt(0); - } - } - - if (!Msg.IsValid()) return false; - - // Process on game thread - FString Response = HandleRequest(Msg->Line); - Msg->Response.SetValue(Response); - - return true; -} // ============================================================ // BuildMCPHandlerRegistry // ============================================================ -void FMCPServer::BuildMCPHandlerRegistry() +void UMCPServer::BuildMCPHandlerRegistry() { - for (TObjectIterator It; It; ++It) + for (UClass* Class : MCPUtils::CollectHandlerClasses()) { - UClass* Class = *It; - if (!Class->ImplementsInterface(UMCPHandler::StaticClass())) - { - continue; - } - if (Class->HasAnyClassFlags(CLASS_Abstract)) - { - continue; - } - FString ToolName = MCPUtils::GetToolName(Class); - if (MCPHandlerRegistry.Contains(ToolName)) - { - UE_LOG(LogTemp, Warning, TEXT("BlueprintMCP: Duplicate tool name '%s' on %s — skipping."), *ToolName, *Class->GetName()); - continue; - } - MCPHandlerRegistry.Add(ToolName, Class); - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Registered handler '%s' → %s"), *ToolName, *Class->GetName()); + MCPHandlerRegistry.FindOrAdd(MCPUtils::GetToolName(Class)) = Class; } - - UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: %d new-style handlers registered."), MCPHandlerRegistry.Num()); } - - diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPUtils.cpp b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPUtils.cpp index b64f66fa..6e664ebb 100644 --- a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPUtils.cpp +++ b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPUtils.cpp @@ -1499,6 +1499,24 @@ bool MCPUtils::PopulateFromJson( return true; } +// ============================================================ +// CollectHandlerClasses — find all concrete IMCPHandler classes +// ============================================================ + +TArray MCPUtils::CollectHandlerClasses() +{ + TArray Result; + for (TObjectIterator It; It; ++It) + { + UClass* Class = *It; + if (Class->HasAnyClassFlags(CLASS_Abstract)) continue; + if (!Class->ImplementsInterface(UMCPHandler::StaticClass())) continue; + Result.Add(Class); + } + Result.Sort([](UClass& A, UClass& B) { return GetToolName(&A) < GetToolName(&B); }); + return Result; +} + // ============================================================ // GetToolName — derive tool name from handler class name // ============================================================ diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Public/MCPCommandlet.h b/Plugins/BlueprintMCP/Source/BlueprintMCP/Public/MCPCommandlet.h index 053dee47..dd7c935d 100644 --- a/Plugins/BlueprintMCP/Source/BlueprintMCP/Public/MCPCommandlet.h +++ b/Plugins/BlueprintMCP/Source/BlueprintMCP/Public/MCPCommandlet.h @@ -2,14 +2,13 @@ #include "CoreMinimal.h" #include "Commandlets/Commandlet.h" -#include "MCPServer.h" #include "MCPCommandlet.generated.h" /** - * Standalone commandlet that hosts the Blueprint MCP HTTP server. - * Delegates all logic to FBlueprintMCPServer and runs a manual engine tick loop. + * Commandlet that keeps the engine alive so the BlueprintMCP editor subsystem + * can serve MCP requests without the full editor UI. * - * Usage: UnrealEditor-Cmd.exe Project.uproject -run=BlueprintMCP [-port=9847] + * Usage: UnrealEditor-Cmd.exe Project.uproject -run=BlueprintMCP */ UCLASS() class UBlueprintMCPCommandlet : public UCommandlet @@ -19,7 +18,4 @@ class UBlueprintMCPCommandlet : public UCommandlet public: UBlueprintMCPCommandlet(); virtual int32 Main(const FString& Params) override; - -private: - TUniquePtr Server; }; diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Public/MCPEditorSubsystem.h b/Plugins/BlueprintMCP/Source/BlueprintMCP/Public/MCPEditorSubsystem.h index f43cc341..03dccdf8 100644 --- a/Plugins/BlueprintMCP/Source/BlueprintMCP/Public/MCPEditorSubsystem.h +++ b/Plugins/BlueprintMCP/Source/BlueprintMCP/Public/MCPEditorSubsystem.h @@ -2,38 +2,22 @@ #include "CoreMinimal.h" #include "EditorSubsystem.h" -#include "Tickable.h" #include "UObject/ObjectSaveContext.h" -#include "MCPServer.h" #include "MCPEditorSubsystem.generated.h" /** - * Editor subsystem that hosts the Blueprint MCP HTTP server inside the running - * UE5 editor. When active, the MCP TypeScript wrapper connects instantly - * (no commandlet spawn, no extra RAM). - * - * Requests are dequeued and processed on the editor's game thread via - * FTickableEditorObject::Tick(). + * Editor subsystem that exports blueprint text files whenever an asset is saved. */ UCLASS() -class UBlueprintMCPEditorSubsystem : public UEditorSubsystem, public FTickableEditorObject +class UBlueprintMCPEditorSubsystem : public UEditorSubsystem { GENERATED_BODY() public: - // UEditorSubsystem virtual void Initialize(FSubsystemCollectionBase& Collection) override; virtual void Deinitialize() override; - // FTickableEditorObject - virtual void Tick(float DeltaTime) override; - virtual bool IsTickable() const override; - virtual TStatId GetStatId() const override; - - FBlueprintMCPServer* GetServer() const { return Server.Get(); } - private: void OnAssetSaved(const FString& PackageFilename, UPackage* Package, FObjectPostSaveContext Context); FDelegateHandle OnAssetSavedHandle; - TUniquePtr Server; }; diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Public/MCPServer.h b/Plugins/BlueprintMCP/Source/BlueprintMCP/Public/MCPServer.h index 1a1fdbf6..0532c2c1 100644 --- a/Plugins/BlueprintMCP/Source/BlueprintMCP/Public/MCPServer.h +++ b/Plugins/BlueprintMCP/Source/BlueprintMCP/Public/MCPServer.h @@ -1,15 +1,18 @@ #pragma once #include "CoreMinimal.h" +#include "EditorSubsystem.h" +#include "Tickable.h" #include "Async/Future.h" #include "Dom/JsonObject.h" #include "MCPUtils.h" +#include "MCPServer.generated.h" + class FSocket; -class IMCPHandler; /** - * FMCPServer — plain C++ class (not a UCLASS) that listens on a TCP - * socket and dispatches JSON commands to blueprint editing handlers. + * UMCPServer — editor subsystem that listens on a TCP socket and dispatches + * JSON commands to blueprint editing handlers. * * Clients connect via TCP and exchange newline-delimited JSON messages. * Request format: {"command": "tool_name", "param1": "value1", ...} @@ -18,30 +21,26 @@ class IMCPHandler; * Each connected client gets its own thread for blocking I/O; * tool calls are dispatched on the game thread. * - * Both the standalone commandlet (UBlueprintMCPCommandlet) and the in-editor - * subsystem (UBlueprintMCPEditorSubsystem) delegate to an instance of this - * class. The only difference is *who ticks the engine*: - * - Commandlet: manual FTSTicker loop - * - Editor subsystem: UE editor tick via FTickableEditorObject + * In the editor, FTickableEditorObject drives the tick. + * In commandlet mode, the commandlet ticks us directly. */ -class FMCPServer +UCLASS() +class UMCPServer : public UEditorSubsystem, public FTickableEditorObject { + GENERATED_BODY() + public: - /** Get the active server instance via the editor subsystem. */ - static FMCPServer* Get(); + /** Get the active server instance via GEditor. */ + static UMCPServer* Get(); - /** Start listening for MCP clients on the given TCP port. */ - bool Start(int32 InPort, bool bEditorMode = false); + // UEditorSubsystem + virtual void Initialize(FSubsystemCollectionBase& Collection) override; + virtual void Deinitialize() override; - /** Stop the server: drain pending requests, close all sockets, join threads. */ - void Stop(); - - /** - * Process pending MCP requests on the game thread. - * Call this every tick from whichever host owns this server. - * Returns true if a request was processed. - */ - bool ProcessOneRequest(); + // FTickableEditorObject + virtual void Tick(float DeltaTime) override; + virtual bool IsTickable() const override; + virtual TStatId GetStatId() const override; /** Whether the server is currently listening. */ bool IsRunning() const { return bRunning; } @@ -50,13 +49,11 @@ public: int32 GetPort() const { return Port; } private: + // ----- Tool dispatch ----- TMap MCPHandlerRegistry; // tool name -> UMCPHandler subclass void BuildMCPHandlerRegistry(); - // Dispatch a tool call to the appropriate handler, returning the response string. - FString DispatchToolCall(const FString& ToolName, const FJsonObject* Params); - // Handle a complete JSON line and return the response JSON FString HandleRequest(const FString& Line); @@ -64,7 +61,6 @@ private: FSocket* ListenSocket = nullptr; int32 Port = 9847; bool bRunning = false; - bool bIsEditor = false; // ----- Client connections ----- struct FClientConnection @@ -76,7 +72,7 @@ private: TArray> Clients; void AcceptNewConnections(); void CleanupFinishedClients(); - static void ClientThreadFunc(FMCPServer* Server, TSharedPtr Client); + static void ClientThreadFunc(UMCPServer* Server, TSharedPtr Client); // ----- Thread-safe message queue ----- struct FPendingMessage @@ -89,6 +85,3 @@ private: TArray> PendingMessages; bool bShuttingDown = false; }; - -using FBlueprintMCPServer = FMCPServer; - diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Public/MCPUtils.h b/Plugins/BlueprintMCP/Source/BlueprintMCP/Public/MCPUtils.h index 6f02bef7..a888a831 100644 --- a/Plugins/BlueprintMCP/Source/BlueprintMCP/Public/MCPUtils.h +++ b/Plugins/BlueprintMCP/Source/BlueprintMCP/Public/MCPUtils.h @@ -199,6 +199,10 @@ public: static bool PopulateFromJson(UStruct* StructType, void* Container, const TSharedPtr& JsonValue, MCPErrorCallback Error); static bool PopulateFromJson(UStruct* StructType, void* Container, const FJsonObject* Json, MCPErrorCallback Error); + // ----- Handler discovery ----- + // Collect all concrete IMCPHandler classes, sorted by tool name. + static TArray CollectHandlerClasses(); + // ----- Command help ----- // Derive tool name from handler class name: "MCPHandler_FooBar" → "FooBar" static FString GetToolName(UClass* HandlerClass);