Simplify MCPServer
This commit is contained in:
@@ -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<TPair<FString, UClass*>> CollectHandlers() const
|
||||
{
|
||||
TArray<TPair<FString, UClass*>> Handlers;
|
||||
for (TObjectIterator<UClass> It; It; ++It)
|
||||
{
|
||||
UClass* Class = *It;
|
||||
if (Class->HasAnyClassFlags(CLASS_Abstract)) continue;
|
||||
const IMCPHandler* Handler = Cast<IMCPHandler>(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<FProperty> 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<FProperty> 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"));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -12,50 +12,26 @@ UBlueprintMCPCommandlet::UBlueprintMCPCommandlet()
|
||||
|
||||
int32 UBlueprintMCPCommandlet::Main(const FString& Params)
|
||||
{
|
||||
// Parse port from command-line params
|
||||
TArray<FString> Tokens;
|
||||
TArray<FString> Switches;
|
||||
TMap<FString, FString> 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<FBlueprintMCPServer>();
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<FBlueprintMCPServer>();
|
||||
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;
|
||||
|
||||
@@ -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<UBlueprintMCPEditorSubsystem>();
|
||||
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<UObject> HandlerObj(NewObject<UObject>(GetTransientPackage(), *HandlerClass));
|
||||
IMCPHandler* Handler = Cast<IMCPHandler>(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<FJsonObject> JsonResult = MakeShared<FJsonObject>();
|
||||
Handler->Handle(Params, &*JsonResult);
|
||||
return MCPUtils::JsonToString(JsonResult);
|
||||
return GEditor->GetEditorSubsystem<UMCPServer>();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// HandleRequest — parse a JSON command and dispatch
|
||||
// Initialization and Shutdown
|
||||
// ============================================================
|
||||
|
||||
FString FMCPServer::HandleRequest(const FString& Line)
|
||||
void UMCPServer::Initialize(FSubsystemCollectionBase& Collection)
|
||||
{
|
||||
TSharedPtr<FJsonObject> Request = MCPUtils::ParseBodyJson(Line);
|
||||
if (!Request.IsValid())
|
||||
{
|
||||
TSharedRef<FJsonObject> ErrResult = MakeShared<FJsonObject>();
|
||||
MCPUtils::MakeErrorJson(&*ErrResult, TEXT("JSON parse error"));
|
||||
return MCPUtils::JsonToString(ErrResult);
|
||||
}
|
||||
|
||||
FString Command;
|
||||
if (!Request->TryGetStringField(TEXT("command"), Command))
|
||||
{
|
||||
TSharedRef<FJsonObject> ErrResult = MakeShared<FJsonObject>();
|
||||
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<FPendingMessage> 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<FJsonObject> 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<UObject> HandlerObj(NewObject<UObject>(GetTransientPackage(), *HandlerClass));
|
||||
IMCPHandler* Handler = Cast<IMCPHandler>(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<FJsonObject> JsonResult = MakeShared<FJsonObject>();
|
||||
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<FClientConnection> Client)
|
||||
void UMCPServer::ClientThreadFunc(UMCPServer* Server, TSharedPtr<FClientConnection> Client)
|
||||
{
|
||||
FSocket* Socket = Client->Socket;
|
||||
FString LineBuffer;
|
||||
@@ -344,7 +385,7 @@ void FMCPServer::ClientThreadFunc(FMCPServer* Server, TSharedPtr<FClientConnecti
|
||||
if (Line.IsEmpty()) continue;
|
||||
|
||||
// Enqueue the line for game-thread processing
|
||||
TSharedPtr<FMCPServer::FPendingMessage> Msg = MakeShared<FMCPServer::FPendingMessage>();
|
||||
TSharedPtr<UMCPServer::FPendingMessage> Msg = MakeShared<UMCPServer::FPendingMessage>();
|
||||
Msg->Line = Line;
|
||||
TFuture<FString> Future = Msg->Response.GetFuture();
|
||||
|
||||
@@ -375,62 +416,15 @@ void FMCPServer::ClientThreadFunc(FMCPServer* Server, TSharedPtr<FClientConnecti
|
||||
UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Client disconnected."));
|
||||
}
|
||||
|
||||
bool FMCPServer::ProcessOneRequest()
|
||||
{
|
||||
// Accept new connections (non-blocking)
|
||||
AcceptNewConnections();
|
||||
|
||||
// Clean up finished client threads
|
||||
CleanupFinishedClients();
|
||||
|
||||
// Dequeue one pending message
|
||||
TSharedPtr<FPendingMessage> 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<UClass> 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());
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1499,6 +1499,24 @@ bool MCPUtils::PopulateFromJson(
|
||||
return true;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// CollectHandlerClasses — find all concrete IMCPHandler classes
|
||||
// ============================================================
|
||||
|
||||
TArray<UClass*> MCPUtils::CollectHandlerClasses()
|
||||
{
|
||||
TArray<UClass*> Result;
|
||||
for (TObjectIterator<UClass> 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
|
||||
// ============================================================
|
||||
|
||||
@@ -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<FBlueprintMCPServer> Server;
|
||||
};
|
||||
|
||||
@@ -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<FBlueprintMCPServer> Server;
|
||||
};
|
||||
|
||||
@@ -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<FString, UClass*> 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<TSharedPtr<FClientConnection>> Clients;
|
||||
void AcceptNewConnections();
|
||||
void CleanupFinishedClients();
|
||||
static void ClientThreadFunc(FMCPServer* Server, TSharedPtr<FClientConnection> Client);
|
||||
static void ClientThreadFunc(UMCPServer* Server, TSharedPtr<FClientConnection> Client);
|
||||
|
||||
// ----- Thread-safe message queue -----
|
||||
struct FPendingMessage
|
||||
@@ -89,6 +85,3 @@ private:
|
||||
TArray<TSharedPtr<FPendingMessage>> PendingMessages;
|
||||
bool bShuttingDown = false;
|
||||
};
|
||||
|
||||
using FBlueprintMCPServer = FMCPServer;
|
||||
|
||||
|
||||
@@ -199,6 +199,10 @@ public:
|
||||
static bool PopulateFromJson(UStruct* StructType, void* Container, const TSharedPtr<FJsonValue>& 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<UClass*> CollectHandlerClasses();
|
||||
|
||||
// ----- Command help -----
|
||||
// Derive tool name from handler class name: "MCPHandler_FooBar" → "FooBar"
|
||||
static FString GetToolName(UClass* HandlerClass);
|
||||
|
||||
Reference in New Issue
Block a user