Simplify MCPServer

This commit is contained in:
2026-03-09 03:44:35 -04:00
parent aceec452d6
commit 642e3aca0a
9 changed files with 201 additions and 313 deletions

View File

@@ -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"));
}
}
};

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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());
}

View File

@@ -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
// ============================================================