How to create new MCP tools for gengine — FMCPToolBase, parameter validation, annotations, registration, testing, and a complete example.
Tool Development
gengine's tool system is extensible. You can add new operations to existing domain tools or create entirely new domain tools. This page covers the FMCPToolBase interface, validation helpers, annotation factory methods, registration, test patterns, and a complete working example.
FMCPToolBase
Every tool handler inherits from FMCPToolBase and implements two methods: GetInfo() and Execute().
// Source/Gengine/Private/MCP/MCPToolBase.h
struct FMCPToolInfo
{
FString Name;
FString Description;
TArray<FMCPParamDef> Params;
TArray<EMCPAnnotation> Annotations;
EMCPSafetyTier SafetyRequirement;
};
class FMCPToolBase
{
public:
virtual ~FMCPToolBase() = default;
/** Return static metadata — called once at registration. */
virtual FMCPToolInfo GetInfo() const = 0;
/** Execute the operation. Called on the game thread. */
virtual FMCPResult Execute(const FJsonObject& Params) = 0;
/** Optional: override to check safety annotations at dispatch time. */
virtual bool HasAnnotation(EMCPAnnotation Annotation) const;
/** Optional: override to declare the minimum safety tier required. */
virtual EMCPSafetyTier GetSafetyRequirement() const;
};
Implementing GetInfo()
GetInfo() declares the tool's name, description, parameter schema, annotations, and safety tier. This data feeds tool discovery — the AI reads it during the MCP initialize handshake.
FMCPToolInfo FMCPTool_TextureAudit::GetInfo() const
{
FMCPToolInfo Info;
Info.Name = TEXT("texture_audit");
Info.Description = TEXT("Audit texture assets in a content path for oversized dimensions or missing compression settings.");
Info.SafetyRequirement = EMCPSafetyTier::ReadOnly;
Info.Annotations = {
EMCPAnnotation::ReadOnly
};
// Required parameter
Info.Params.Add(FMCPParamDef::Required(
TEXT("path"),
TEXT("Content path to audit, e.g. /Game/Textures/"),
EMCPParamType::String
));
// Optional parameter with default
Info.Params.Add(FMCPParamDef::Optional(
TEXT("max_dimension"),
TEXT("Flag textures larger than this dimension (width or height). Default: 2048."),
EMCPParamType::Integer
));
Info.Params.Add(FMCPParamDef::Optional(
TEXT("recursive"),
TEXT("Include subdirectories. Default: false."),
EMCPParamType::Bool
));
return Info;
}
Parameter definition helpers
| Helper | Usage |
|---|---|
FMCPParamDef::Required(name, desc, type) | Mandatory parameter — validator rejects calls that omit it |
FMCPParamDef::Optional(name, desc, type) | Optional parameter — validator passes calls that omit it |
Param types
EMCPParamType | JSON type |
|---|---|
String | string |
Integer | number (integer) |
Float | number (float) |
Bool | boolean |
Object | object |
Array | array |
Implementing Execute()
Execute() runs on the game thread. Keep it non-blocking — use AsyncTask for heavy work and return a task handle.
FMCPResult FMCPTool_TextureAudit::Execute(const FJsonObject& Params)
{
// 1. Extract and validate parameters
FString ContentPath;
if (!Params.TryGetStringField(TEXT("path"), ContentPath))
{
return FMCPResult::Error(TEXT("Missing required parameter: path"));
}
// Validate path — no traversal, no absolute paths
if (!FMCPParamValidator::IsValidContentPath(ContentPath))
{
return FMCPResult::Error(TEXT("Invalid content path"));
}
int32 MaxDimension = 2048;
Params.TryGetNumberField(TEXT("max_dimension"), MaxDimension);
bool bRecursive = false;
Params.TryGetBoolField(TEXT("recursive"), bRecursive);
// 2. Do the work using UE APIs (game thread — safe to call asset registry)
FAssetRegistryModule& AssetReg =
FModuleManager::LoadModuleChecked<FAssetRegistryModule>(TEXT("AssetRegistry"));
FARFilter Filter;
Filter.PackagePaths.Add(*ContentPath);
Filter.ClassPaths.Add(UTexture2D::StaticClass()->GetClassPathName());
Filter.bRecursivePaths = bRecursive;
TArray<FAssetData> Assets;
AssetReg.Get().GetAssets(Filter, Assets);
// 3. Build result
TArray<TSharedPtr<FJsonValue>> Violations;
for (const FAssetData& Asset : Assets)
{
UTexture2D* Tex = Cast<UTexture2D>(Asset.GetAsset());
if (!Tex) continue;
const int32 Width = Tex->GetSizeX();
const int32 Height = Tex->GetSizeY();
if (Width > MaxDimension || Height > MaxDimension)
{
TSharedPtr<FJsonObject> Entry = MakeShared<FJsonObject>();
Entry->SetStringField(TEXT("path"), Asset.GetObjectPathString());
Entry->SetNumberField(TEXT("width"), Width);
Entry->SetNumberField(TEXT("height"), Height);
Entry->SetStringField(TEXT("issue"), TEXT("Exceeds max dimension"));
Violations.Add(MakeShared<FJsonValueObject>(Entry));
}
}
TSharedPtr<FJsonObject> ResultObj = MakeShared<FJsonObject>();
ResultObj->SetNumberField(TEXT("total_checked"), Assets.Num());
ResultObj->SetNumberField(TEXT("violations_found"), Violations.Num());
ResultObj->SetArrayField(TEXT("violations"), Violations);
return FMCPResult::Success(ResultObj);
}
Result helpers
| Method | Usage |
|---|---|
FMCPResult::Success(JsonObject) | Operation completed, return structured data |
FMCPResult::Success(FString) | Operation completed, return plain text |
FMCPResult::Error(FString) | Operation failed, return error message |
FMCPResult::ValidationError(FString) | Parameter was invalid — triggers 400-style response |
Validator Helpers
Use FMCPParamValidator static methods for common checks rather than rolling your own:
// Path safety
FMCPParamValidator::IsValidContentPath(FString Path) // /Game/... format, no traversal
FMCPParamValidator::IsValidActorName(FString Name) // no special chars, no traversal
FMCPParamValidator::IsValidAssetName(FString Name) // alphanumeric + underscores
// Numeric safety
FMCPParamValidator::IsFiniteFloat(float Value) // not NaN, not Inf
FMCPParamValidator::IsValidIntRange(int32 Value, int32 Min, int32 Max)
// String safety
FMCPParamValidator::ContainsInjectionPatterns(FString S) // <script>, javascript:, etc.
FMCPParamValidator::ContainsPathTraversal(FString S) // ../, ..\, etc.
Annotation Factory Methods
Declare annotations in GetInfo() using the EMCPAnnotation enum:
// Read-only — does not modify editor state
Info.Annotations = { EMCPAnnotation::ReadOnly };
// Destructive — may permanently delete data
Info.Annotations = { EMCPAnnotation::Destructive };
// Idempotent — calling twice produces same result
Info.Annotations = { EMCPAnnotation::Idempotent };
// Open-world — may affect external systems
Info.Annotations = { EMCPAnnotation::OpenWorld };
// Combine as needed
Info.Annotations = { EMCPAnnotation::Idempotent, EMCPAnnotation::OpenWorld };
Registration
Register tools in StartupModule() of GengineModule.cpp:
// Source/Gengine/Private/GengineModule.cpp
void FGengineModule::StartupModule()
{
// ... existing registrations ...
// Register your new tool
FMCPToolRegistry::Get().RegisterTool(
MakeShared<FMCPTool_TextureAudit>()
);
}
That's it. The tool appears in tools/list discovery on the next editor session and is immediately callable by AI clients.
Test Patterns
Tests live in Source/Gengine/Private/Tests/. Follow the existing test pattern:
// Source/Gengine/Private/Tests/MCPTool_TextureAudit_Test.cpp
#include "CoreMinimal.h"
#include "Misc/AutomationTest.h"
#include "MCP/Tools/MCPTool_TextureAudit.h"
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FMCPTool_TextureAudit_ValidPath,
"gengine.Tools.TextureAudit.ValidPath",
EAutomationTestFlags::ApplicationContextMask | EAutomationTestFlags::ProductFilter
)
bool FMCPTool_TextureAudit_ValidPath::RunTest(const FString& Parameters)
{
FMCPTool_TextureAudit Tool;
// Build params
TSharedPtr<FJsonObject> Params = MakeShared<FJsonObject>();
Params->SetStringField(TEXT("path"), TEXT("/Game/"));
Params->SetNumberField(TEXT("max_dimension"), 1024);
FMCPResult Result = Tool.Execute(*Params);
TestTrue("Result is success", Result.bSuccess);
TestTrue("Has total_checked field", Result.Data->HasField(TEXT("total_checked")));
return true;
}
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FMCPTool_TextureAudit_MissingPath,
"gengine.Tools.TextureAudit.MissingPath",
EAutomationTestFlags::ApplicationContextMask | EAutomationTestFlags::ProductFilter
)
bool FMCPTool_TextureAudit_MissingPath::RunTest(const FString& Parameters)
{
FMCPTool_TextureAudit Tool;
TSharedPtr<FJsonObject> EmptyParams = MakeShared<FJsonObject>();
FMCPResult Result = Tool.Execute(*EmptyParams);
TestFalse("Result is error for missing path", Result.bSuccess);
TestTrue("Error message mentions path", Result.ErrorMessage.Contains(TEXT("path")));
return true;
}
Run tests with:
Automation RunTests gengine.Tools.TextureAudit
Or all gengine tests:
Automation RunTests gengine
Complete Example: TextureAudit Tool
Putting it all together — a complete read-only tool that audits textures for oversized dimensions:
Header (MCPTool_TextureAudit.h):
#pragma once
#include "MCP/MCPToolBase.h"
class FMCPTool_TextureAudit : public FMCPToolBase
{
public:
virtual FMCPToolInfo GetInfo() const override;
virtual FMCPResult Execute(const FJsonObject& Params) override;
};
Implementation (MCPTool_TextureAudit.cpp): see Execute() example above.
Registration (in GengineModule.cpp):
FMCPToolRegistry::Get().RegisterTool(MakeShared<FMCPTool_TextureAudit>());
After registration, an AI client can call:
{
"tool": "texture_audit",
"operation": "query",
"params": {
"path": "/Game/Environment/",
"max_dimension": 2048,
"recursive": true
}
}
And receives:
{
"total_checked": 47,
"violations_found": 3,
"violations": [
{ "path": "/Game/Environment/Rocks/T_Boulder_Diffuse", "width": 4096, "height": 4096, "issue": "Exceeds max dimension" },
{ "path": "/Game/Environment/Trees/T_Oak_Normal", "width": 4096, "height": 2048, "issue": "Exceeds max dimension" },
{ "path": "/Game/Environment/Ground/T_Mud_Diffuse", "width": 4096, "height": 4096, "issue": "Exceeds max dimension" }
]
}