Simplify MCPServer
This commit is contained in:
@@ -2,21 +2,9 @@
|
|||||||
|
|
||||||
#include "CoreMinimal.h"
|
#include "CoreMinimal.h"
|
||||||
#include "MCPHandler.h"
|
#include "MCPHandler.h"
|
||||||
#include "MCPAssetFinder.h"
|
|
||||||
#include "MCPUtils.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"
|
#include "UMCPHandler_ShowCommands.generated.h"
|
||||||
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
UCLASS()
|
UCLASS()
|
||||||
class UMCPHandler_ShowCommands : public UObject, public IMCPHandler
|
class UMCPHandler_ShowCommands : public UObject, public IMCPHandler
|
||||||
{
|
{
|
||||||
@@ -31,49 +19,28 @@ public:
|
|||||||
return TEXT("List all available commands with their descriptions.");
|
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
|
virtual void Handle(const FJsonObject* Json, FStringBuilderBase& Result) override
|
||||||
{
|
{
|
||||||
auto Handlers = CollectHandlers();
|
for (UClass* Class : MCPUtils::CollectHandlerClasses())
|
||||||
Result.Appendf(TEXT("%d commands:\n"), Handlers.Num());
|
|
||||||
|
|
||||||
for (const auto& Pair : Handlers)
|
|
||||||
{
|
{
|
||||||
if (Verbose)
|
if (Verbose)
|
||||||
{
|
{
|
||||||
MCPUtils::FormatCommandHelp(Pair.Value, Result);
|
MCPUtils::FormatCommandHelp(Class, Result);
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
else
|
||||||
// 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)
|
|
||||||
{
|
{
|
||||||
if (!bFirst) Result.Append(TEXT(","));
|
Result.Append(MCPUtils::GetToolName(Class));
|
||||||
bFirst = false;
|
Result.Append(TEXT("("));
|
||||||
if (PropIt->HasMetaData(TEXT("Optional"))) Result.Append(TEXT("?"));
|
bool bFirst = true;
|
||||||
Result.Append(MCPUtils::PropertyNameToJsonKey(PropIt->GetName()));
|
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)
|
int32 UBlueprintMCPCommandlet::Main(const FString& Params)
|
||||||
{
|
{
|
||||||
// Parse port from command-line params
|
// The UMCPServer editor subsystem starts the server automatically.
|
||||||
TArray<FString> Tokens;
|
// We just need to tick it, since FTickableEditorObject doesn't tick in commandlet mode.
|
||||||
TArray<FString> Switches;
|
UMCPServer* Server = UMCPServer::Get();
|
||||||
TMap<FString, FString> ParamMap;
|
if (!Server)
|
||||||
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))
|
|
||||||
{
|
{
|
||||||
|
UE_LOG(LogTemp, Error, TEXT("BlueprintMCP: Could not find MCP server subsystem"));
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Main loop — tick the engine systems and process queued requests one at a time
|
|
||||||
double LastTime = FPlatformTime::Seconds();
|
double LastTime = FPlatformTime::Seconds();
|
||||||
|
|
||||||
auto TickEngine = [&LastTime]()
|
while (!IsEngineExitRequested())
|
||||||
{
|
{
|
||||||
double CurrentTime = FPlatformTime::Seconds();
|
double CurrentTime = FPlatformTime::Seconds();
|
||||||
double DeltaTime = CurrentTime - LastTime;
|
double DeltaTime = CurrentTime - LastTime;
|
||||||
LastTime = CurrentTime;
|
LastTime = CurrentTime;
|
||||||
FTSTicker::GetCoreTicker().Tick(DeltaTime);
|
FTSTicker::GetCoreTicker().Tick(DeltaTime);
|
||||||
};
|
Server->Tick(DeltaTime);
|
||||||
|
|
||||||
while (!IsEngineExitRequested())
|
|
||||||
{
|
|
||||||
TickEngine();
|
|
||||||
|
|
||||||
if (Server->ProcessOneRequest())
|
|
||||||
{
|
|
||||||
// Tick again immediately after completing a request so pending
|
|
||||||
// HTTP responses get flushed.
|
|
||||||
TickEngine();
|
|
||||||
}
|
|
||||||
|
|
||||||
FPlatformProcess::Sleep(0.01f);
|
FPlatformProcess::Sleep(0.01f);
|
||||||
}
|
}
|
||||||
|
|
||||||
Server->Stop();
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
#include "MCPEditorSubsystem.h"
|
#include "MCPEditorSubsystem.h"
|
||||||
#include "MCPServer.h"
|
|
||||||
#include "BlueprintExporter.h"
|
#include "BlueprintExporter.h"
|
||||||
#include "Engine/Blueprint.h"
|
#include "Engine/Blueprint.h"
|
||||||
#include "EdGraph/EdGraph.h"
|
#include "EdGraph/EdGraph.h"
|
||||||
@@ -11,59 +10,16 @@ void UBlueprintMCPEditorSubsystem::Initialize(FSubsystemCollectionBase& Collecti
|
|||||||
{
|
{
|
||||||
Super::Initialize(Collection);
|
Super::Initialize(Collection);
|
||||||
|
|
||||||
// Don't start in commandlet mode — the commandlet has its own server instance.
|
|
||||||
if (IsRunningCommandlet())
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
OnAssetSavedHandle = UPackage::PackageSavedWithContextEvent.AddUObject(
|
OnAssetSavedHandle = UPackage::PackageSavedWithContextEvent.AddUObject(
|
||||||
this, &UBlueprintMCPEditorSubsystem::OnAssetSaved);
|
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()
|
void UBlueprintMCPEditorSubsystem::Deinitialize()
|
||||||
{
|
{
|
||||||
UPackage::PackageSavedWithContextEvent.Remove(OnAssetSavedHandle);
|
UPackage::PackageSavedWithContextEvent.Remove(OnAssetSavedHandle);
|
||||||
|
|
||||||
if (Server)
|
|
||||||
{
|
|
||||||
Server->Stop();
|
|
||||||
Server.Reset();
|
|
||||||
UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Editor subsystem stopped."));
|
|
||||||
}
|
|
||||||
|
|
||||||
Super::Deinitialize();
|
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)
|
void UBlueprintMCPEditorSubsystem::OnAssetSaved(const FString& PackageFilename, UPackage* Package, FObjectPostSaveContext Context)
|
||||||
{
|
{
|
||||||
if (!Package) return;
|
if (!Package) return;
|
||||||
|
|||||||
@@ -103,90 +103,19 @@
|
|||||||
// Get() — retrieve the active server via the editor subsystem
|
// Get() — retrieve the active server via the editor subsystem
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
#include "MCPEditorSubsystem.h"
|
UMCPServer* UMCPServer::Get()
|
||||||
|
|
||||||
FMCPServer* FMCPServer::Get()
|
|
||||||
{
|
{
|
||||||
if (!GEditor) return nullptr;
|
if (!GEditor) return nullptr;
|
||||||
auto* Sub = GEditor->GetEditorSubsystem<UBlueprintMCPEditorSubsystem>();
|
return GEditor->GetEditorSubsystem<UMCPServer>();
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 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);
|
Super::Initialize(Collection);
|
||||||
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();
|
|
||||||
|
|
||||||
// Create TCP listen socket
|
// Create TCP listen socket
|
||||||
ISocketSubsystem* SocketSub = ISocketSubsystem::Get(PLATFORM_SOCKETSUBSYSTEM);
|
ISocketSubsystem* SocketSub = ISocketSubsystem::Get(PLATFORM_SOCKETSUBSYSTEM);
|
||||||
@@ -194,7 +123,7 @@ bool FMCPServer::Start(int32 InPort, bool bEditorMode)
|
|||||||
if (!ListenSocket)
|
if (!ListenSocket)
|
||||||
{
|
{
|
||||||
UE_LOG(LogTemp, Error, TEXT("BlueprintMCP: Failed to create listen socket"));
|
UE_LOG(LogTemp, Error, TEXT("BlueprintMCP: Failed to create listen socket"));
|
||||||
return false;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ListenSocket->SetReuseAddr(true);
|
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);
|
UE_LOG(LogTemp, Error, TEXT("BlueprintMCP: Failed to bind to port %d"), Port);
|
||||||
SocketSub->DestroySocket(ListenSocket);
|
SocketSub->DestroySocket(ListenSocket);
|
||||||
ListenSocket = nullptr;
|
ListenSocket = nullptr;
|
||||||
return false;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ListenSocket->Listen(4))
|
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);
|
UE_LOG(LogTemp, Error, TEXT("BlueprintMCP: Failed to listen on port %d"), Port);
|
||||||
SocketSub->DestroySocket(ListenSocket);
|
SocketSub->DestroySocket(ListenSocket);
|
||||||
ListenSocket = nullptr;
|
ListenSocket = nullptr;
|
||||||
return false;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
BuildMCPHandlerRegistry();
|
||||||
bRunning = true;
|
bRunning = true;
|
||||||
UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: MCP server listening on tcp://localhost:%d"), Port);
|
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);
|
ISocketSubsystem* SocketSub = ISocketSubsystem::Get(PLATFORM_SOCKETSUBSYSTEM);
|
||||||
|
|
||||||
@@ -274,9 +207,117 @@ void FMCPServer::Stop()
|
|||||||
bRunning = false;
|
bRunning = false;
|
||||||
bShuttingDown = false;
|
bShuttingDown = false;
|
||||||
UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Server stopped."));
|
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;
|
if (!ListenSocket) return;
|
||||||
|
|
||||||
@@ -296,7 +337,7 @@ void FMCPServer::AcceptNewConnections()
|
|||||||
UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Client connected."));
|
UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Client connected."));
|
||||||
}
|
}
|
||||||
|
|
||||||
void FMCPServer::CleanupFinishedClients()
|
void UMCPServer::CleanupFinishedClients()
|
||||||
{
|
{
|
||||||
ISocketSubsystem* SocketSub = ISocketSubsystem::Get(PLATFORM_SOCKETSUBSYSTEM);
|
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;
|
FSocket* Socket = Client->Socket;
|
||||||
FString LineBuffer;
|
FString LineBuffer;
|
||||||
@@ -344,7 +385,7 @@ void FMCPServer::ClientThreadFunc(FMCPServer* Server, TSharedPtr<FClientConnecti
|
|||||||
if (Line.IsEmpty()) continue;
|
if (Line.IsEmpty()) continue;
|
||||||
|
|
||||||
// Enqueue the line for game-thread processing
|
// Enqueue the line for game-thread processing
|
||||||
TSharedPtr<FMCPServer::FPendingMessage> Msg = MakeShared<FMCPServer::FPendingMessage>();
|
TSharedPtr<UMCPServer::FPendingMessage> Msg = MakeShared<UMCPServer::FPendingMessage>();
|
||||||
Msg->Line = Line;
|
Msg->Line = Line;
|
||||||
TFuture<FString> Future = Msg->Response.GetFuture();
|
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."));
|
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
|
// BuildMCPHandlerRegistry
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
void FMCPServer::BuildMCPHandlerRegistry()
|
void UMCPServer::BuildMCPHandlerRegistry()
|
||||||
{
|
{
|
||||||
for (TObjectIterator<UClass> It; It; ++It)
|
for (UClass* Class : MCPUtils::CollectHandlerClasses())
|
||||||
{
|
{
|
||||||
UClass* Class = *It;
|
MCPHandlerRegistry.FindOrAdd(MCPUtils::GetToolName(Class)) = Class;
|
||||||
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());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: %d new-style handlers registered."), MCPHandlerRegistry.Num());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1499,6 +1499,24 @@ bool MCPUtils::PopulateFromJson(
|
|||||||
return true;
|
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
|
// GetToolName — derive tool name from handler class name
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@@ -2,14 +2,13 @@
|
|||||||
|
|
||||||
#include "CoreMinimal.h"
|
#include "CoreMinimal.h"
|
||||||
#include "Commandlets/Commandlet.h"
|
#include "Commandlets/Commandlet.h"
|
||||||
#include "MCPServer.h"
|
|
||||||
#include "MCPCommandlet.generated.h"
|
#include "MCPCommandlet.generated.h"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Standalone commandlet that hosts the Blueprint MCP HTTP server.
|
* Commandlet that keeps the engine alive so the BlueprintMCP editor subsystem
|
||||||
* Delegates all logic to FBlueprintMCPServer and runs a manual engine tick loop.
|
* 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()
|
UCLASS()
|
||||||
class UBlueprintMCPCommandlet : public UCommandlet
|
class UBlueprintMCPCommandlet : public UCommandlet
|
||||||
@@ -19,7 +18,4 @@ class UBlueprintMCPCommandlet : public UCommandlet
|
|||||||
public:
|
public:
|
||||||
UBlueprintMCPCommandlet();
|
UBlueprintMCPCommandlet();
|
||||||
virtual int32 Main(const FString& Params) override;
|
virtual int32 Main(const FString& Params) override;
|
||||||
|
|
||||||
private:
|
|
||||||
TUniquePtr<FBlueprintMCPServer> Server;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,38 +2,22 @@
|
|||||||
|
|
||||||
#include "CoreMinimal.h"
|
#include "CoreMinimal.h"
|
||||||
#include "EditorSubsystem.h"
|
#include "EditorSubsystem.h"
|
||||||
#include "Tickable.h"
|
|
||||||
#include "UObject/ObjectSaveContext.h"
|
#include "UObject/ObjectSaveContext.h"
|
||||||
#include "MCPServer.h"
|
|
||||||
#include "MCPEditorSubsystem.generated.h"
|
#include "MCPEditorSubsystem.generated.h"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Editor subsystem that hosts the Blueprint MCP HTTP server inside the running
|
* Editor subsystem that exports blueprint text files whenever an asset is saved.
|
||||||
* 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().
|
|
||||||
*/
|
*/
|
||||||
UCLASS()
|
UCLASS()
|
||||||
class UBlueprintMCPEditorSubsystem : public UEditorSubsystem, public FTickableEditorObject
|
class UBlueprintMCPEditorSubsystem : public UEditorSubsystem
|
||||||
{
|
{
|
||||||
GENERATED_BODY()
|
GENERATED_BODY()
|
||||||
|
|
||||||
public:
|
public:
|
||||||
// UEditorSubsystem
|
|
||||||
virtual void Initialize(FSubsystemCollectionBase& Collection) override;
|
virtual void Initialize(FSubsystemCollectionBase& Collection) override;
|
||||||
virtual void Deinitialize() 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:
|
private:
|
||||||
void OnAssetSaved(const FString& PackageFilename, UPackage* Package, FObjectPostSaveContext Context);
|
void OnAssetSaved(const FString& PackageFilename, UPackage* Package, FObjectPostSaveContext Context);
|
||||||
FDelegateHandle OnAssetSavedHandle;
|
FDelegateHandle OnAssetSavedHandle;
|
||||||
TUniquePtr<FBlueprintMCPServer> Server;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include "CoreMinimal.h"
|
#include "CoreMinimal.h"
|
||||||
|
#include "EditorSubsystem.h"
|
||||||
|
#include "Tickable.h"
|
||||||
#include "Async/Future.h"
|
#include "Async/Future.h"
|
||||||
#include "Dom/JsonObject.h"
|
#include "Dom/JsonObject.h"
|
||||||
#include "MCPUtils.h"
|
#include "MCPUtils.h"
|
||||||
|
#include "MCPServer.generated.h"
|
||||||
|
|
||||||
class FSocket;
|
class FSocket;
|
||||||
class IMCPHandler;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* FMCPServer — plain C++ class (not a UCLASS) that listens on a TCP
|
* UMCPServer — editor subsystem that listens on a TCP socket and dispatches
|
||||||
* socket and dispatches JSON commands to blueprint editing handlers.
|
* JSON commands to blueprint editing handlers.
|
||||||
*
|
*
|
||||||
* Clients connect via TCP and exchange newline-delimited JSON messages.
|
* Clients connect via TCP and exchange newline-delimited JSON messages.
|
||||||
* Request format: {"command": "tool_name", "param1": "value1", ...}
|
* Request format: {"command": "tool_name", "param1": "value1", ...}
|
||||||
@@ -18,30 +21,26 @@ class IMCPHandler;
|
|||||||
* Each connected client gets its own thread for blocking I/O;
|
* Each connected client gets its own thread for blocking I/O;
|
||||||
* tool calls are dispatched on the game thread.
|
* tool calls are dispatched on the game thread.
|
||||||
*
|
*
|
||||||
* Both the standalone commandlet (UBlueprintMCPCommandlet) and the in-editor
|
* In the editor, FTickableEditorObject drives the tick.
|
||||||
* subsystem (UBlueprintMCPEditorSubsystem) delegate to an instance of this
|
* In commandlet mode, the commandlet ticks us directly.
|
||||||
* class. The only difference is *who ticks the engine*:
|
|
||||||
* - Commandlet: manual FTSTicker loop
|
|
||||||
* - Editor subsystem: UE editor tick via FTickableEditorObject
|
|
||||||
*/
|
*/
|
||||||
class FMCPServer
|
UCLASS()
|
||||||
|
class UMCPServer : public UEditorSubsystem, public FTickableEditorObject
|
||||||
{
|
{
|
||||||
|
GENERATED_BODY()
|
||||||
|
|
||||||
public:
|
public:
|
||||||
/** Get the active server instance via the editor subsystem. */
|
/** Get the active server instance via GEditor. */
|
||||||
static FMCPServer* Get();
|
static UMCPServer* Get();
|
||||||
|
|
||||||
/** Start listening for MCP clients on the given TCP port. */
|
// UEditorSubsystem
|
||||||
bool Start(int32 InPort, bool bEditorMode = false);
|
virtual void Initialize(FSubsystemCollectionBase& Collection) override;
|
||||||
|
virtual void Deinitialize() override;
|
||||||
|
|
||||||
/** Stop the server: drain pending requests, close all sockets, join threads. */
|
// FTickableEditorObject
|
||||||
void Stop();
|
virtual void Tick(float DeltaTime) override;
|
||||||
|
virtual bool IsTickable() const override;
|
||||||
/**
|
virtual TStatId GetStatId() const override;
|
||||||
* 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();
|
|
||||||
|
|
||||||
/** Whether the server is currently listening. */
|
/** Whether the server is currently listening. */
|
||||||
bool IsRunning() const { return bRunning; }
|
bool IsRunning() const { return bRunning; }
|
||||||
@@ -50,13 +49,11 @@ public:
|
|||||||
int32 GetPort() const { return Port; }
|
int32 GetPort() const { return Port; }
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
|
||||||
// ----- Tool dispatch -----
|
// ----- Tool dispatch -----
|
||||||
TMap<FString, UClass*> MCPHandlerRegistry; // tool name -> UMCPHandler subclass
|
TMap<FString, UClass*> MCPHandlerRegistry; // tool name -> UMCPHandler subclass
|
||||||
void BuildMCPHandlerRegistry();
|
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
|
// Handle a complete JSON line and return the response JSON
|
||||||
FString HandleRequest(const FString& Line);
|
FString HandleRequest(const FString& Line);
|
||||||
|
|
||||||
@@ -64,7 +61,6 @@ private:
|
|||||||
FSocket* ListenSocket = nullptr;
|
FSocket* ListenSocket = nullptr;
|
||||||
int32 Port = 9847;
|
int32 Port = 9847;
|
||||||
bool bRunning = false;
|
bool bRunning = false;
|
||||||
bool bIsEditor = false;
|
|
||||||
|
|
||||||
// ----- Client connections -----
|
// ----- Client connections -----
|
||||||
struct FClientConnection
|
struct FClientConnection
|
||||||
@@ -76,7 +72,7 @@ private:
|
|||||||
TArray<TSharedPtr<FClientConnection>> Clients;
|
TArray<TSharedPtr<FClientConnection>> Clients;
|
||||||
void AcceptNewConnections();
|
void AcceptNewConnections();
|
||||||
void CleanupFinishedClients();
|
void CleanupFinishedClients();
|
||||||
static void ClientThreadFunc(FMCPServer* Server, TSharedPtr<FClientConnection> Client);
|
static void ClientThreadFunc(UMCPServer* Server, TSharedPtr<FClientConnection> Client);
|
||||||
|
|
||||||
// ----- Thread-safe message queue -----
|
// ----- Thread-safe message queue -----
|
||||||
struct FPendingMessage
|
struct FPendingMessage
|
||||||
@@ -89,6 +85,3 @@ private:
|
|||||||
TArray<TSharedPtr<FPendingMessage>> PendingMessages;
|
TArray<TSharedPtr<FPendingMessage>> PendingMessages;
|
||||||
bool bShuttingDown = false;
|
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 TSharedPtr<FJsonValue>& JsonValue, MCPErrorCallback Error);
|
||||||
static bool PopulateFromJson(UStruct* StructType, void* Container, const FJsonObject* Json, 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 -----
|
// ----- Command help -----
|
||||||
// Derive tool name from handler class name: "MCPHandler_FooBar" → "FooBar"
|
// Derive tool name from handler class name: "MCPHandler_FooBar" → "FooBar"
|
||||||
static FString GetToolName(UClass* HandlerClass);
|
static FString GetToolName(UClass* HandlerClass);
|
||||||
|
|||||||
Reference in New Issue
Block a user