More work on MCP handlers. Changed the Validation handler.

This commit is contained in:
2026-03-06 21:46:03 -05:00
parent b2e8b231fb
commit 7c66aee47a
12 changed files with 528 additions and 359 deletions

View File

@@ -1,8 +1,8 @@
{
"mcpServers": {
"blueprint-mcp": {
"command": "/usr/bin/nc",
"args": ["localhost", "9847"]
"command": "python3",
"args": ["tools/mcp-bridge.py"]
}
}
}

Binary file not shown.

Binary file not shown.

View File

@@ -59,6 +59,7 @@ UMCPAssetFinder* UMCPAssetFinder::GetUpdatedAssets()
Self->AllBlueprintAssets.Empty();
Self->AllMapAssets.Empty();
Self->AllBlueprintAndMapAssets.Empty();
Self->AllMaterialAssets.Empty();
Self->AllMaterialInstanceAssets.Empty();
Self->AllMaterialFunctionAssets.Empty();
@@ -69,6 +70,9 @@ UMCPAssetFinder* UMCPAssetFinder::GetUpdatedAssets()
AR.GetAssetsByClass(UMaterialInstanceConstant::StaticClass()->GetClassPathName(), Self->AllMaterialInstanceAssets, false);
AR.GetAssetsByClass(UMaterialFunction::StaticClass()->GetClassPathName(), Self->AllMaterialFunctionAssets, false);
Self->AllBlueprintAndMapAssets = Self->AllBlueprintAssets;
Self->AllBlueprintAndMapAssets.Append(Self->AllMapAssets);
Self->bDirty = false;
UE_LOG(LogTemp, Display, TEXT("MCPAssetFinder: Refreshed — BP %d, Map %d, Mat %d, MI %d, MF %d"),
@@ -88,6 +92,12 @@ const TArray<FAssetData>& UMCPAssetFinder::GetBlueprintAssets()
return Self ? Self->AllBlueprintAssets : EmptyAssetArray;
}
const TArray<FAssetData>& UMCPAssetFinder::GetBlueprintAndMapAssets()
{
UMCPAssetFinder* Self = GetUpdatedAssets();
return Self ? Self->AllBlueprintAndMapAssets : EmptyAssetArray;
}
const TArray<FAssetData>& UMCPAssetFinder::GetMapAssets()
{
UMCPAssetFinder* Self = GetUpdatedAssets();
@@ -144,11 +154,34 @@ namespace
}
return Found;
}
TArray<FAssetData*> SearchInList(const TArray<FAssetData>& List, const FString& Filter)
{
TArray<FAssetData*> Results;
for (const FAssetData& Asset : List)
{
FString AssetName = Asset.AssetName.ToString();
FString PackagePath = Asset.PackageName.ToString();
if (AssetName.Contains(Filter, ESearchCase::IgnoreCase) ||
PackagePath.Contains(Filter, ESearchCase::IgnoreCase))
{
Results.Add(const_cast<FAssetData*>(&Asset));
}
}
return Results;
}
}
FAssetData* UMCPAssetFinder::FindBlueprintAsset(const FString& NameOrPath, FString* OutError)
FAssetData* UMCPAssetFinder::FindBlueprintAsset(const FString& NameOrPath, FString* OutError, bool bIncludeLevelBlueprints)
{
return FindInList(GetBlueprintAssets(), NameOrPath, OutError);
const TArray<FAssetData>& List = bIncludeLevelBlueprints ? GetBlueprintAndMapAssets() : GetBlueprintAssets();
return FindInList(List, NameOrPath, OutError);
}
TArray<FAssetData*> UMCPAssetFinder::SearchBlueprintAssets(const FString& Filter, bool bIncludeLevelBlueprints)
{
const TArray<FAssetData>& List = bIncludeLevelBlueprints ? GetBlueprintAndMapAssets() : GetBlueprintAssets();
return SearchInList(List, Filter);
}
FAssetData* UMCPAssetFinder::FindMapAsset(const FString& NameOrPath, FString* OutError)
@@ -184,38 +217,37 @@ FAssetData* UMCPAssetFinder::FindAnyAsset(const FString& NameOrPath, FString* Ou
// Load helpers
// ============================================================
UBlueprint* UMCPAssetFinder::LoadBlueprintByName(const FString& NameOrPath, FString& OutError)
UBlueprint* UMCPAssetFinder::LoadBlueprintByName(const FString& NameOrPath, FString& OutError, bool bIncludeLevelBlueprints)
{
// Strategy 1: Try as a regular Blueprint asset
FAssetData* Asset = FindBlueprintAsset(NameOrPath, &OutError);
if (Asset)
FAssetData* Asset = FindBlueprintAsset(NameOrPath, &OutError, bIncludeLevelBlueprints);
if (!Asset)
{
UBlueprint* BP = Cast<UBlueprint>(Asset->GetAsset());
if (BP) return BP;
}
if (!OutError.IsEmpty()) return nullptr;
// Strategy 2: Try as a level blueprint (from a .umap)
FAssetData* MapAsset = FindMapAsset(NameOrPath, &OutError);
if (MapAsset)
{
UWorld* World = Cast<UWorld>(MapAsset->GetAsset());
if (World && World->PersistentLevel)
if (OutError.IsEmpty())
{
ULevelScriptBlueprint* LevelBP = World->PersistentLevel->GetLevelScriptBlueprint(true);
if (LevelBP)
{
UE_LOG(LogTemp, Display, TEXT("MCPAssetFinder: Loaded level blueprint from map '%s'"),
*NameOrPath);
return LevelBP;
}
OutError = FString::Printf(TEXT("Blueprint '%s' not found. Use list_blueprints to see available assets."), *NameOrPath);
}
OutError = FString::Printf(TEXT("Map '%s' loaded but its level blueprint could not be retrieved. The map may not contain a level blueprint."), *NameOrPath);
return nullptr;
}
if (!OutError.IsEmpty()) return nullptr;
OutError = FString::Printf(TEXT("Blueprint or map '%s' not found. Use list_blueprints to see available assets. Level blueprints are referenced by their map name (e.g. 'MAP_Ward')."), *NameOrPath);
// Regular blueprint asset
UBlueprint* BP = Cast<UBlueprint>(Asset->GetAsset());
if (BP)
{
return BP;
}
// Map asset — extract the level blueprint
UWorld* World = Cast<UWorld>(Asset->GetAsset());
if (World && World->PersistentLevel)
{
ULevelScriptBlueprint* LevelBP = World->PersistentLevel->GetLevelScriptBlueprint(true);
if (LevelBP)
{
return LevelBP;
}
}
OutError = FString::Printf(TEXT("Asset '%s' loaded but its level blueprint could not be retrieved."), *NameOrPath);
return nullptr;
}

View File

@@ -3,3 +3,4 @@
#include "MCPHandlers_Mutation.h"
#include "MCPHandlers_PinMutation.h"
#include "MCPHandlers_AssetMutation.h"
#include "MCPHandlers_Validation.h"

View File

@@ -1,318 +0,0 @@
#include "MCPAssetFinder.h"
#include "MCPServer.h"
#include "MCPUtils.h"
#include "Engine/Blueprint.h"
#include "EdGraph/EdGraph.h"
#include "EdGraph/EdGraphNode.h"
#include "Kismet2/BlueprintEditorUtils.h"
#include "Kismet2/KismetEditorUtilities.h"
#include "Serialization/JsonReader.h"
#include "Serialization/JsonWriter.h"
#include "Serialization/JsonSerializer.h"
// SEH wrapper defined in BlueprintMCPServer.cpp — non-static for cross-TU access
#if PLATFORM_WINDOWS
extern int32 TryCompileBlueprintSEH(UBlueprint* BP, EBlueprintCompileOptions Opts);
#endif
// ============================================================
// Log capture device for intercepting UE_LOG output during compilation
// ============================================================
class FCompileLogCapture : public FOutputDevice
{
public:
TArray<FString> CapturedErrors;
TArray<FString> CapturedWarnings;
virtual void Serialize(const TCHAR* V, ELogVerbosity::Type Verbosity, const FName& Category) override
{
FString Msg(V);
if (Verbosity == ELogVerbosity::Error || Verbosity == ELogVerbosity::Fatal)
{
CapturedErrors.Add(Msg);
return;
}
if (Verbosity == ELogVerbosity::Warning)
{
if (!Msg.Contains(TEXT("BlueprintMCP:")))
{
CapturedWarnings.Add(Msg);
}
return;
}
static const TCHAR* ErrorPatterns[] = {
TEXT("Can't connect pins"),
TEXT("Fixed up function"),
TEXT("is not compatible with"),
TEXT("could not find a pin"),
TEXT("has an invalid"),
TEXT("orphaned pin"),
TEXT("is deprecated"),
TEXT("does not implement"),
TEXT("Missing function"),
TEXT("Unable to find"),
TEXT("Failed to resolve"),
};
for (const TCHAR* Pattern : ErrorPatterns)
{
if (Msg.Contains(Pattern))
{
CapturedWarnings.Add(Msg);
return;
}
}
}
};
// Helper: validate a single Blueprint and return structured JSON result
static TSharedRef<FJsonObject> ValidateSingleBlueprint(UBlueprint* BP, const FString& BlueprintName)
{
FCompileLogCapture LogCapture;
GLog->AddOutputDevice(&LogCapture);
EBlueprintCompileOptions CompileOpts =
EBlueprintCompileOptions::SkipSave |
EBlueprintCompileOptions::SkipGarbageCollection |
EBlueprintCompileOptions::SkipFiBSearchMetaUpdate;
bool bCompileCrashed = false;
#if PLATFORM_WINDOWS
int32 CompileResult = TryCompileBlueprintSEH(BP, CompileOpts);
if (CompileResult != 0)
{
bCompileCrashed = true;
}
#else
FKismetEditorUtilities::CompileBlueprint(BP, CompileOpts, nullptr);
#endif
GLog->RemoveOutputDevice(&LogCapture);
TArray<TSharedPtr<FJsonValue>> ErrorsArr;
TArray<TSharedPtr<FJsonValue>> WarningsArr;
TArray<UEdGraph*> AllGraphs;
BP->GetAllGraphs(AllGraphs);
for (UEdGraph* Graph : AllGraphs)
{
if (!Graph) continue;
for (UEdGraphNode* Node : Graph->Nodes)
{
if (!Node) continue;
if (Node->bHasCompilerMessage)
{
TSharedRef<FJsonObject> Msg = MakeShared<FJsonObject>();
Msg->SetStringField(TEXT("graph"), Graph->GetName());
Msg->SetStringField(TEXT("nodeId"), Node->NodeGuid.ToString());
Msg->SetStringField(TEXT("nodeTitle"), Node->GetNodeTitle(ENodeTitleType::FullTitle).ToString());
Msg->SetStringField(TEXT("nodeClass"), Node->GetClass()->GetName());
Msg->SetStringField(TEXT("message"), Node->ErrorMsg);
if (Node->ErrorType == EMessageSeverity::Error)
{
Msg->SetStringField(TEXT("severity"), TEXT("error"));
ErrorsArr.Add(MakeShared<FJsonValueObject>(Msg));
}
else
{
Msg->SetStringField(TEXT("severity"), TEXT("warning"));
WarningsArr.Add(MakeShared<FJsonValueObject>(Msg));
}
}
}
}
for (const FString& LogErr : LogCapture.CapturedErrors)
{
TSharedRef<FJsonObject> Msg = MakeShared<FJsonObject>();
Msg->SetStringField(TEXT("source"), TEXT("log"));
Msg->SetStringField(TEXT("message"), LogErr);
Msg->SetStringField(TEXT("severity"), TEXT("error"));
ErrorsArr.Add(MakeShared<FJsonValueObject>(Msg));
}
for (const FString& LogWarn : LogCapture.CapturedWarnings)
{
TSharedRef<FJsonObject> Msg = MakeShared<FJsonObject>();
Msg->SetStringField(TEXT("source"), TEXT("log"));
Msg->SetStringField(TEXT("message"), LogWarn);
Msg->SetStringField(TEXT("severity"), TEXT("warning"));
WarningsArr.Add(MakeShared<FJsonValueObject>(Msg));
}
FString StatusStr;
switch (BP->Status)
{
case BS_UpToDate: StatusStr = TEXT("UpToDate"); break;
case BS_Dirty: StatusStr = TEXT("Dirty"); break;
case BS_Error: StatusStr = TEXT("Error"); break;
case BS_Unknown: StatusStr = TEXT("Unknown"); break;
default: StatusStr = FString::Printf(TEXT("Status_%d"), (int32)BP->Status); break;
}
bool bIsValid = (BP->Status == BS_UpToDate) && ErrorsArr.Num() == 0;
TSharedRef<FJsonObject> Result = MakeShared<FJsonObject>();
Result->SetStringField(TEXT("blueprint"), BlueprintName);
Result->SetStringField(TEXT("status"), StatusStr);
Result->SetBoolField(TEXT("isValid"), bIsValid);
Result->SetNumberField(TEXT("errorCount"), ErrorsArr.Num());
Result->SetArrayField(TEXT("errors"), ErrorsArr);
Result->SetNumberField(TEXT("warningCount"), WarningsArr.Num());
Result->SetArrayField(TEXT("warnings"), WarningsArr);
if (bCompileCrashed)
{
Result->SetStringField(TEXT("compileWarning"), TEXT("Compilation crashed (SEH caught), results may be incomplete"));
}
return Result;
}
// HandleValidateBlueprint — compile without saving, report errors + captured log messages
// ============================================================
void FBlueprintMCPServer::HandleValidateBlueprint(const FJsonObject* Json, FJsonObject* Result)
{
FString BlueprintName = Json->GetStringField(TEXT("blueprint"));
if (BlueprintName.IsEmpty())
{
return MCPUtils::MakeErrorJson(Result, TEXT("Missing required field: blueprint"));
}
// Load Blueprint
FString LoadError;
UBlueprint* BP = UMCPAssetFinder::LoadBlueprintByName(BlueprintName, LoadError);
if (!BP)
{
return MCPUtils::MakeErrorJson(Result, LoadError);
}
UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Validating blueprint '%s'"), *BlueprintName);
TSharedRef<FJsonObject> ValidationResult = ValidateSingleBlueprint(BP, BlueprintName);
MCPUtils::CopyJsonFields(&*ValidationResult, Result);
}
// ============================================================
// HandleValidateAllBlueprints — bulk validation
// ============================================================
void FBlueprintMCPServer::HandleValidateAllBlueprints(const FJsonObject* Json, FJsonObject* Result)
{
FString Filter = Json->GetStringField(TEXT("filter"));
bool bCountOnly = Json->GetBoolField(TEXT("countOnly"));
int32 Offset = (int32)Json->GetNumberField(TEXT("offset"));
int32 Limit = (int32)Json->GetNumberField(TEXT("limit"));
// First pass: collect matching asset indices (string comparisons only, no GetAsset())
TArray<int32> MatchingIndices;
for (int32 i = 0; i < UMCPAssetFinder::GetBlueprintAssets().Num(); i++)
{
const FAssetData& Asset = UMCPAssetFinder::GetBlueprintAssets()[i];
if (!Filter.IsEmpty())
{
FString AssetName = Asset.AssetName.ToString();
FString PackagePath = Asset.PackageName.ToString();
if (!PackagePath.Contains(Filter) && !AssetName.Contains(Filter))
{
continue;
}
}
MatchingIndices.Add(i);
}
int32 TotalMatching = MatchingIndices.Num();
// countOnly: return count without compiling anything
if (bCountOnly)
{
Result->SetNumberField(TEXT("totalMatching"), TotalMatching);
if (!Filter.IsEmpty())
{
Result->SetStringField(TEXT("filter"), Filter);
}
return;
}
// Compute range
int32 StartIdx = FMath::Clamp(Offset, 0, TotalMatching);
int32 EndIdx = (Limit > 0) ? FMath::Min(StartIdx + Limit, TotalMatching) : TotalMatching;
UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Bulk validating blueprints (filter: '%s', range: %d-%d of %d matching)"),
Filter.IsEmpty() ? TEXT("*") : *Filter, StartIdx, EndIdx, TotalMatching);
TArray<TSharedPtr<FJsonValue>> FailedArr;
int32 TotalChecked = 0;
int32 TotalPassed = 0;
int32 TotalFailed = 0;
int32 TotalCrashed = 0;
for (int32 Idx = StartIdx; Idx < EndIdx; Idx++)
{
const FAssetData& Asset = UMCPAssetFinder::GetBlueprintAssets()[MatchingIndices[Idx]];
FString AssetName = Asset.AssetName.ToString();
FString PackagePath = Asset.PackageName.ToString();
// Load the Blueprint
UBlueprint* BP = Cast<UBlueprint>(Asset.GetAsset());
if (!BP)
{
continue;
}
TotalChecked++;
TSharedRef<FJsonObject> ValidationResult = ValidateSingleBlueprint(BP, AssetName);
bool bValid = ValidationResult->GetBoolField(TEXT("isValid"));
int32 Errors = (int32)ValidationResult->GetNumberField(TEXT("errorCount"));
int32 Warnings = (int32)ValidationResult->GetNumberField(TEXT("warningCount"));
if (ValidationResult->HasField(TEXT("compileWarning")))
{
TotalCrashed++;
}
if (bValid && Errors == 0)
{
TotalPassed++;
}
else
{
TotalFailed++;
// Include path for context in bulk results
ValidationResult->SetStringField(TEXT("path"), PackagePath);
FailedArr.Add(MakeShared<FJsonValueObject>(ValidationResult));
}
// Log progress every 50 blueprints
if (TotalChecked % 50 == 0)
{
UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Validated %d blueprints so far (%d failed)..."),
TotalChecked, TotalFailed);
}
}
UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Bulk validation complete — %d checked, %d passed, %d failed, %d crashed"),
TotalChecked, TotalPassed, TotalFailed, TotalCrashed);
Result->SetNumberField(TEXT("totalMatching"), TotalMatching);
Result->SetNumberField(TEXT("totalChecked"), TotalChecked);
Result->SetNumberField(TEXT("totalPassed"), TotalPassed);
Result->SetNumberField(TEXT("totalFailed"), TotalFailed);
if (TotalCrashed > 0)
{
Result->SetNumberField(TEXT("totalCrashed"), TotalCrashed);
}
Result->SetArrayField(TEXT("failed"), FailedArr);
if (!Filter.IsEmpty())
{
Result->SetStringField(TEXT("filter"), Filter);
}
}

View File

@@ -0,0 +1,240 @@
#pragma once
#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 "Kismet2/BlueprintEditorUtils.h"
#include "Kismet2/KismetEditorUtilities.h"
#include "MCPHandlers_Validation.generated.h"
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
UCLASS(meta=(ToolName="compile_blueprint"))
class UMCPHandler_CompileBlueprint : public UObject, public IMCPHandler
{
GENERATED_BODY()
public:
UPROPERTY(meta=(Optional, Description="Blueprint name or package path. If specified, compile this single blueprint."))
FString Blueprint;
UPROPERTY(meta=(Optional, Description="Substring search query. If specified instead of blueprint, compile all matching blueprints."))
FString Query;
UPROPERTY(meta=(Optional, Description="If true, return the count of matching blueprints without compiling."))
bool CountOnly = false;
UPROPERTY(meta=(Optional, Description="Starting index for pagination (default 0)."))
int32 Offset = 0;
UPROPERTY(meta=(Optional, Description="Maximum number of blueprints to compile (default 0 = no limit)."))
int32 Limit = 0;
virtual FString GetDescription() const override
{
return TEXT("Compile one or more blueprints without saving. "
"Reports errors and warnings. "
"Use 'blueprint' for a single blueprint, or 'query' to bulk-compile matching blueprints.");
}
// Helper: validate a single Blueprint and return structured JSON result
static TSharedRef<FJsonObject> ValidateSingleBlueprint(UBlueprint* BP, const FString& BlueprintName)
{
FLogCaptureOutputDevice LogCapture;
GLog->AddOutputDevice(&LogCapture);
EBlueprintCompileOptions CompileOpts =
EBlueprintCompileOptions::SkipSave |
EBlueprintCompileOptions::SkipGarbageCollection |
EBlueprintCompileOptions::SkipFiBSearchMetaUpdate;
FKismetEditorUtilities::CompileBlueprint(BP, CompileOpts, nullptr);
GLog->RemoveOutputDevice(&LogCapture);
TArray<TSharedPtr<FJsonValue>> ErrorsArr;
TArray<TSharedPtr<FJsonValue>> WarningsArr;
TArray<UEdGraph*> AllGraphs;
BP->GetAllGraphs(AllGraphs);
for (UEdGraph* Graph : AllGraphs)
{
if (!Graph) continue;
for (UEdGraphNode* Node : Graph->Nodes)
{
if (!Node) continue;
if (Node->bHasCompilerMessage)
{
TSharedRef<FJsonObject> Msg = MakeShared<FJsonObject>();
Msg->SetStringField(TEXT("graph"), Graph->GetName());
Msg->SetStringField(TEXT("nodeId"), Node->NodeGuid.ToString());
Msg->SetStringField(TEXT("nodeTitle"), Node->GetNodeTitle(ENodeTitleType::FullTitle).ToString());
Msg->SetStringField(TEXT("nodeClass"), Node->GetClass()->GetName());
Msg->SetStringField(TEXT("message"), Node->ErrorMsg);
if (Node->ErrorType == EMessageSeverity::Error)
{
Msg->SetStringField(TEXT("severity"), TEXT("error"));
ErrorsArr.Add(MakeShared<FJsonValueObject>(Msg));
}
else
{
Msg->SetStringField(TEXT("severity"), TEXT("warning"));
WarningsArr.Add(MakeShared<FJsonValueObject>(Msg));
}
}
}
}
for (const FString& LogErr : LogCapture.CapturedErrors)
{
TSharedRef<FJsonObject> Msg = MakeShared<FJsonObject>();
Msg->SetStringField(TEXT("source"), TEXT("log"));
Msg->SetStringField(TEXT("message"), LogErr);
Msg->SetStringField(TEXT("severity"), TEXT("error"));
ErrorsArr.Add(MakeShared<FJsonValueObject>(Msg));
}
for (const FString& LogWarn : LogCapture.CapturedWarnings)
{
TSharedRef<FJsonObject> Msg = MakeShared<FJsonObject>();
Msg->SetStringField(TEXT("source"), TEXT("log"));
Msg->SetStringField(TEXT("message"), LogWarn);
Msg->SetStringField(TEXT("severity"), TEXT("warning"));
WarningsArr.Add(MakeShared<FJsonValueObject>(Msg));
}
FString StatusStr;
switch (BP->Status)
{
case BS_UpToDate: StatusStr = TEXT("UpToDate"); break;
case BS_Dirty: StatusStr = TEXT("Dirty"); break;
case BS_Error: StatusStr = TEXT("Error"); break;
case BS_Unknown: StatusStr = TEXT("Unknown"); break;
default: StatusStr = FString::Printf(TEXT("Status_%d"), (int32)BP->Status); break;
}
bool bIsValid = (BP->Status == BS_UpToDate) && ErrorsArr.Num() == 0;
TSharedRef<FJsonObject> Result = MakeShared<FJsonObject>();
Result->SetStringField(TEXT("blueprint"), BlueprintName);
Result->SetStringField(TEXT("status"), StatusStr);
Result->SetBoolField(TEXT("isValid"), bIsValid);
Result->SetNumberField(TEXT("errorCount"), ErrorsArr.Num());
Result->SetArrayField(TEXT("errors"), ErrorsArr);
Result->SetNumberField(TEXT("warningCount"), WarningsArr.Num());
Result->SetArrayField(TEXT("warnings"), WarningsArr);
return Result;
}
virtual void Handle(const FJsonObject* Json, FJsonObject* Result) override
{
TArray<FAssetData*> MatchingAssets;
if (!Blueprint.IsEmpty())
{
FAssetData* Asset = UMCPAssetFinder::FindBlueprintAsset(Blueprint, nullptr, /*bIncludeLevelBlueprints=*/true);
if (!Asset)
{
return MCPUtils::MakeErrorJson(Result, FString::Printf(TEXT("Blueprint '%s' not found."), *Blueprint));
}
MatchingAssets.Add(Asset);
}
else
{
MatchingAssets = UMCPAssetFinder::SearchBlueprintAssets(Query, /*bIncludeLevelBlueprints=*/true);
}
int32 TotalMatching = MatchingAssets.Num();
// countOnly: return count without compiling anything
if (CountOnly)
{
Result->SetNumberField(TEXT("totalMatching"), TotalMatching);
if (!Query.IsEmpty())
{
Result->SetStringField(TEXT("query"), Query);
}
return;
}
// Compute range
int32 StartIdx = FMath::Clamp(Offset, 0, TotalMatching);
int32 EndIdx = (Limit > 0) ? FMath::Min(StartIdx + Limit, TotalMatching) : TotalMatching;
UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Bulk validating blueprints (query: '%s', range: %d-%d of %d matching)"),
Query.IsEmpty() ? TEXT("*") : *Query, StartIdx, EndIdx, TotalMatching);
TArray<TSharedPtr<FJsonValue>> FailedArr;
TArray<TSharedPtr<FJsonValue>> WarningsArr;
int32 TotalChecked = 0;
int32 TotalPassed = 0;
int32 TotalFailed = 0;
for (int32 Idx = StartIdx; Idx < EndIdx; Idx++)
{
FAssetData* Asset = MatchingAssets[Idx];
FString AssetName = Asset->AssetName.ToString();
FString PackagePath = Asset->PackageName.ToString();
// Load the Blueprint (handles both regular and level blueprints)
FString LoadError;
UBlueprint* BP = UMCPAssetFinder::LoadBlueprintByName(PackagePath, LoadError, /*bIncludeLevelBlueprints=*/true);
if (!BP)
{
continue;
}
TotalChecked++;
TSharedRef<FJsonObject> ValidationResult = ValidateSingleBlueprint(BP, AssetName);
bool bValid = ValidationResult->GetBoolField(TEXT("isValid"));
int32 Errors = (int32)ValidationResult->GetNumberField(TEXT("errorCount"));
int32 Warnings = (int32)ValidationResult->GetNumberField(TEXT("warningCount"));
if (bValid && Errors == 0)
{
TotalPassed++;
if (Warnings > 0)
{
ValidationResult->SetStringField(TEXT("path"), PackagePath);
WarningsArr.Add(MakeShared<FJsonValueObject>(ValidationResult));
}
}
else
{
TotalFailed++;
ValidationResult->SetStringField(TEXT("path"), PackagePath);
FailedArr.Add(MakeShared<FJsonValueObject>(ValidationResult));
}
// Log progress every 50 blueprints
if (TotalChecked % 50 == 0)
{
UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Validated %d blueprints so far (%d failed)..."),
TotalChecked, TotalFailed);
}
}
UE_LOG(LogTemp, Display, TEXT("BlueprintMCP: Bulk validation complete — %d checked, %d passed, %d failed"),
TotalChecked, TotalPassed, TotalFailed);
Result->SetNumberField(TEXT("totalMatching"), TotalMatching);
Result->SetNumberField(TEXT("totalChecked"), TotalChecked);
Result->SetNumberField(TEXT("totalPassed"), TotalPassed);
Result->SetNumberField(TEXT("totalFailed"), TotalFailed);
Result->SetArrayField(TEXT("failed"), FailedArr);
Result->SetArrayField(TEXT("warnings"), WarningsArr);
if (!Query.IsEmpty())
{
Result->SetStringField(TEXT("query"), Query);
}
}
};

View File

@@ -910,8 +910,6 @@ void FMCPServer::RegisterHandlers()
H(TEXT("search_unreal_classes"), &FMCPServer::HandleListClasses);
H(TEXT("list_class_functions"), &FMCPServer::HandleListFunctions);
H(TEXT("list_class_properties"), &FMCPServer::HandleListProperties);
H(TEXT("compile_blueprint"), &FMCPServer::HandleValidateBlueprint);
H(TEXT("compile_all_blueprints"), &FMCPServer::HandleValidateAllBlueprints);
H(TEXT("reparent_blueprint"), &FMCPServer::HandleReparentBlueprint);
H(TEXT("create_blueprint_asset"), &FMCPServer::HandleCreateBlueprint);
H(TEXT("create_blueprint_graph"), &FMCPServer::HandleCreateGraph);

View File

@@ -25,6 +25,7 @@ public:
virtual void Deinitialize() override;
// --- Static API: asset lists ---
static const TArray<FAssetData>& GetBlueprintAssets();
static const TArray<FAssetData>& GetBlueprintAndMapAssets();
static const TArray<FAssetData>& GetMapAssets();
static const TArray<FAssetData>& GetMaterialAssets();
static const TArray<FAssetData>& GetMaterialInstanceAssets();
@@ -34,9 +35,10 @@ public:
// Find functions return nullptr if not found or if the short name is ambiguous.
// Pass OutError to get a descriptive message on failure.
static FAssetData* FindAnyAsset(const FString& NameOrPath, FString* OutError = nullptr);
static FAssetData* FindBlueprintAsset(const FString& NameOrPath, FString* OutError = nullptr);
static FAssetData* FindBlueprintAsset(const FString& NameOrPath, FString* OutError = nullptr, bool bIncludeLevelBlueprints = false);
static TArray<FAssetData*> SearchBlueprintAssets(const FString& Filter, bool bIncludeLevelBlueprints = false);
static FAssetData* FindMapAsset(const FString& NameOrPath, FString* OutError = nullptr);
static UBlueprint* LoadBlueprintByName(const FString& NameOrPath, FString& OutError);
static UBlueprint* LoadBlueprintByName(const FString& NameOrPath, FString& OutError, bool bIncludeLevelBlueprints = true);
static FAssetData* FindMaterialAsset(const FString& NameOrPath, FString* OutError = nullptr);
static UMaterial* LoadMaterialByName(const FString& NameOrPath, FString& OutError);
@@ -52,6 +54,7 @@ private:
// Cached asset lists
TArray<FAssetData> AllBlueprintAssets;
TArray<FAssetData> AllMapAssets;
TArray<FAssetData> AllBlueprintAndMapAssets;
TArray<FAssetData> AllMaterialAssets;
TArray<FAssetData> AllMaterialInstanceAssets;
TArray<FAssetData> AllMaterialFunctionAssets;

View File

@@ -119,10 +119,6 @@ private:
void HandleChangeFunctionParamType(const FJsonObject* Json, FJsonObject* Result);
void HandleRemoveFunctionParameter(const FJsonObject* Json, FJsonObject* Result);
// ----- Validation (read-only, no save) -----
void HandleValidateBlueprint(const FJsonObject* Json, FJsonObject* Result);
void HandleValidateAllBlueprints(const FJsonObject* Json, FJsonObject* Result);
// ----- Pin introspection (read-only) -----
void HandleGetPinInfo(const FJsonObject* Json, FJsonObject* Result);
void HandleCheckPinCompatibility(const FJsonObject* Json, FJsonObject* Result);

View File

@@ -48,6 +48,58 @@ struct FGraphSnapshot
TMap<FString, FGraphSnapshotData> Graphs; // graphName -> data
};
// ----- Log capture -----
class FLogCaptureOutputDevice : public FOutputDevice
{
public:
TArray<FString> CapturedErrors;
TArray<FString> CapturedWarnings;
virtual void Serialize(const TCHAR* V, ELogVerbosity::Type Verbosity, const FName& Category) override
{
FString Msg(V);
if (Verbosity == ELogVerbosity::Error || Verbosity == ELogVerbosity::Fatal)
{
CapturedErrors.Add(Msg);
return;
}
if (Verbosity == ELogVerbosity::Warning)
{
if (!Msg.Contains(TEXT("BlueprintMCP:")))
{
CapturedWarnings.Add(Msg);
}
return;
}
static const TCHAR* ErrorPatterns[] = {
TEXT("Can't connect pins"),
TEXT("Fixed up function"),
TEXT("is not compatible with"),
TEXT("could not find a pin"),
TEXT("has an invalid"),
TEXT("orphaned pin"),
TEXT("is deprecated"),
TEXT("does not implement"),
TEXT("Missing function"),
TEXT("Unable to find"),
TEXT("Failed to resolve"),
};
for (const TCHAR* Pattern : ErrorPatterns)
{
if (Msg.Contains(Pattern))
{
CapturedWarnings.Add(Msg);
return;
}
}
}
};
// Stateless utility functions used by MCP handlers and the MCP server.
// This is effectively a namespace — all methods are static.
class MCPUtils

165
tools/mcp-bridge.py Normal file
View File

@@ -0,0 +1,165 @@
#!/usr/bin/env python3
"""
MCP stdio-to-TCP bridge for BlueprintMCP.
Reads JSON-RPC messages from stdin, forwards them to the BlueprintMCP TCP server,
and writes responses to stdout. If the editor isn't running, returns valid MCP
error responses instead of crashing.
"""
import sys
import json
import socket
import time
HOST = "localhost"
PORT = 9847
CONNECT_TIMEOUT = 2
READ_TIMEOUT = 120
sock = None
def connect():
"""Try to connect to the editor. Returns True on success."""
global sock
if sock is not None:
return True
try:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(CONNECT_TIMEOUT)
s.connect((HOST, PORT))
s.settimeout(READ_TIMEOUT)
sock = s
return True
except (ConnectionRefusedError, socket.timeout, OSError):
return False
def disconnect():
global sock
if sock is not None:
try:
sock.close()
except Exception:
pass
sock = None
def send_and_receive(message):
"""Send a JSON-RPC message to the editor and return the response."""
data = json.dumps(message) + "\n"
sock.sendall(data.encode())
result = b""
while True:
chunk = sock.recv(65536)
if not chunk:
raise ConnectionError("Connection closed")
result += chunk
try:
return json.loads(result)
except json.JSONDecodeError:
continue
def editor_down_response(msg):
"""Return a valid MCP error response indicating the editor is down."""
msg_id = msg.get("id", 0)
method = msg.get("method", "")
if method == "initialize":
return {
"jsonrpc": "2.0",
"id": msg_id,
"result": {
"protocolVersion": "2024-11-05",
"capabilities": {"tools": {}},
"serverInfo": {"name": "blueprint-mcp", "version": "1.0.0"},
"_notice": "Unreal Editor is not running. No tools are available. Start the editor and restart Claude Code.",
},
}
if method == "notifications/initialized":
return None # No response needed for notifications
if method == "tools/list":
return {
"jsonrpc": "2.0",
"id": msg_id,
"result": {
"tools": [{
"name": "editor_not_running",
"description": "Unreal Editor is not running. Start the editor and restart Claude Code to get BlueprintMCP tools.",
"inputSchema": {"type": "object", "properties": {}},
}],
},
}
if method == "tools/call":
return {
"jsonrpc": "2.0",
"id": msg_id,
"result": {
"content": [
{"type": "text", "text": json.dumps({"error": "Unreal Editor is down."})}
],
"isError": True,
},
}
return {
"jsonrpc": "2.0",
"id": msg_id,
"error": {"code": -32000, "message": "Unreal Editor is down."},
}
def handle_message(msg):
"""Handle one JSON-RPC message. Try the editor first, fall back to offline responses."""
method = msg.get("method", "")
# Notifications don't get responses
if "id" not in msg:
if connect():
try:
sock.sendall((json.dumps(msg) + "\n").encode())
except Exception:
disconnect()
return None
# Try connecting (or reconnecting) to the editor
if not connect():
return editor_down_response(msg)
try:
return send_and_receive(msg)
except Exception:
disconnect()
# Retry once in case the connection was stale
if connect():
try:
return send_and_receive(msg)
except Exception:
disconnect()
return editor_down_response(msg)
def main():
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
msg = json.loads(line)
except json.JSONDecodeError:
continue
response = handle_message(msg)
if response is not None:
sys.stdout.write(json.dumps(response) + "\n")
sys.stdout.flush()
if __name__ == "__main__":
main()