Skip to content

Commit 90053d1

Browse files
committed
Add add_mass_config_trait tool + PyPI auto-publish (v1.2.1)
New MCP tool: add_mass_config_trait - Safely appends a single trait to a MassEntityConfigAsset - Never replaces existing traits (prevents replicator data loss) - Duplicate detection (returns error if trait class already exists) - Supports property initialization via JSON dict - C++ bridge: EpicUnrealMCPDataAssetCommands.cpp - Python wrapper: data_assets.py - Go CLI: data_assets.go CI/CD: Added PyPI publishing to release workflow - Builds sdist + wheel via python -m build - Publishes via twine using PYPI_TOKEN secret - Version auto-synced from git tag - Triggers on v* tag push alongside npm publish Version bumped to 1.2.1 (pyproject.toml + package.json)
1 parent 4b495f5 commit 90053d1

8 files changed

Lines changed: 231 additions & 3 deletions

File tree

.github/workflows/release.yml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,3 +120,33 @@ jobs:
120120
env:
121121
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
122122
run: npm publish --access public
123+
124+
publish-pypi:
125+
needs: release
126+
runs-on: ubuntu-latest
127+
128+
steps:
129+
- name: Checkout
130+
uses: actions/checkout@v4
131+
132+
- name: Set up Python
133+
uses: actions/setup-python@v5
134+
with:
135+
python-version: '3.12'
136+
137+
- name: Install build tools
138+
run: pip install build twine
139+
140+
- name: Update version from tag
141+
run: |
142+
VERSION="${GITHUB_REF_NAME#v}"
143+
sed -i "s/^version = .*/version = \"$VERSION\"/" pyproject.toml
144+
145+
- name: Build package
146+
run: python -m build
147+
148+
- name: Publish to PyPI
149+
env:
150+
TWINE_USERNAME: __token__
151+
TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}
152+
run: twine upload dist/*

Source/UnrealMCPBridge/Private/Commands/EpicUnrealMCPDataAssetCommands.cpp

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,10 @@ TSharedPtr<FJsonObject> FEpicUnrealMCPDataAssetCommands::HandleCommand(
5858
{
5959
return HandleGetMassConfigTraits(Params);
6060
}
61+
if (CommandType == TEXT("add_mass_config_trait"))
62+
{
63+
return HandleAddMassConfigTrait(Params);
64+
}
6165

6266
return FEpicUnrealMCPCommonUtils::CreateErrorResponse(
6367
FString::Printf(TEXT("Unknown data asset command: %s"), *CommandType));
@@ -1047,3 +1051,156 @@ TSharedPtr<FJsonObject> FEpicUnrealMCPDataAssetCommands::HandleGetMassConfigTrai
10471051
Result->SetArrayField(TEXT("traits"), TraitsArray);
10481052
return Result;
10491053
}
1054+
1055+
// ============================================================================
1056+
// add_mass_config_trait — Add a SINGLE trait to a Mass Entity Config without
1057+
// replacing existing traits. Safe for configs with complex replicator settings.
1058+
// ============================================================================
1059+
1060+
TSharedPtr<FJsonObject> FEpicUnrealMCPDataAssetCommands::HandleAddMassConfigTrait(
1061+
const TSharedPtr<FJsonObject>& Params)
1062+
{
1063+
FString AssetPath;
1064+
if (!Params->TryGetStringField(TEXT("asset_path"), AssetPath))
1065+
{
1066+
return FEpicUnrealMCPCommonUtils::CreateErrorResponse(TEXT("Missing 'asset_path'"));
1067+
}
1068+
1069+
FString TraitClassName;
1070+
if (!Params->TryGetStringField(TEXT("trait_class"), TraitClassName))
1071+
{
1072+
return FEpicUnrealMCPCommonUtils::CreateErrorResponse(TEXT("Missing 'trait_class' (e.g. '/Script/Jiggify.MassBuildingHealthTrait')"));
1073+
}
1074+
1075+
UObject* Asset = StaticLoadObject(UObject::StaticClass(), nullptr, *AssetPath);
1076+
if (!Asset)
1077+
{
1078+
return FEpicUnrealMCPCommonUtils::CreateErrorResponse(
1079+
FString::Printf(TEXT("Asset not found: %s"), *AssetPath));
1080+
}
1081+
1082+
// Resolve trait class
1083+
UClass* TraitClass = StaticLoadClass(UObject::StaticClass(), nullptr, *TraitClassName);
1084+
if (!TraitClass)
1085+
{
1086+
return FEpicUnrealMCPCommonUtils::CreateErrorResponse(
1087+
FString::Printf(TEXT("Trait class not found: %s"), *TraitClassName));
1088+
}
1089+
1090+
// Find Config struct property
1091+
FStructProperty* ConfigProp = nullptr;
1092+
for (TFieldIterator<FStructProperty> It(Asset->GetClass()); It; ++It)
1093+
{
1094+
if (It->GetName() == TEXT("Config"))
1095+
{
1096+
ConfigProp = *It;
1097+
break;
1098+
}
1099+
}
1100+
1101+
if (!ConfigProp)
1102+
{
1103+
return FEpicUnrealMCPCommonUtils::CreateErrorResponse(TEXT("Asset has no 'Config' struct property"));
1104+
}
1105+
1106+
void* ConfigPtr = ConfigProp->ContainerPtrToValuePtr<void>(Asset);
1107+
1108+
// Find Traits array
1109+
FArrayProperty* TraitsArrayProp = nullptr;
1110+
for (TFieldIterator<FArrayProperty> It(ConfigProp->Struct); It; ++It)
1111+
{
1112+
if (It->GetName() == TEXT("Traits"))
1113+
{
1114+
TraitsArrayProp = *It;
1115+
break;
1116+
}
1117+
}
1118+
1119+
if (!TraitsArrayProp)
1120+
{
1121+
return FEpicUnrealMCPCommonUtils::CreateErrorResponse(TEXT("Config has no 'Traits' array"));
1122+
}
1123+
1124+
FObjectProperty* InnerObjProp = CastField<FObjectProperty>(TraitsArrayProp->Inner);
1125+
if (!InnerObjProp)
1126+
{
1127+
return FEpicUnrealMCPCommonUtils::CreateErrorResponse(TEXT("Traits array inner type is not an object"));
1128+
}
1129+
1130+
// Check if trait already exists
1131+
FScriptArrayHelper ArrayHelper(TraitsArrayProp, TraitsArrayProp->ContainerPtrToValuePtr<void>(ConfigPtr));
1132+
for (int32 i = 0; i < ArrayHelper.Num(); ++i)
1133+
{
1134+
UObject* Existing = InnerObjProp->GetObjectPropertyValue(ArrayHelper.GetRawPtr(i));
1135+
if (Existing && Existing->GetClass() == TraitClass)
1136+
{
1137+
return FEpicUnrealMCPCommonUtils::CreateErrorResponse(
1138+
FString::Printf(TEXT("Trait '%s' already exists at index %d"), *TraitClass->GetName(), i));
1139+
}
1140+
}
1141+
1142+
// Create new trait instance
1143+
UObject* NewTrait = NewObject<UObject>(Asset, TraitClass);
1144+
if (!NewTrait)
1145+
{
1146+
return FEpicUnrealMCPCommonUtils::CreateErrorResponse(TEXT("Failed to create trait instance"));
1147+
}
1148+
1149+
// Set properties if provided
1150+
const TSharedPtr<FJsonObject>* PropertiesObj = nullptr;
1151+
if (Params->TryGetObjectField(TEXT("properties"), PropertiesObj) && PropertiesObj)
1152+
{
1153+
for (auto& Pair : (*PropertiesObj)->Values)
1154+
{
1155+
FProperty* Prop = NewTrait->GetClass()->FindPropertyByName(*Pair.Key);
1156+
if (Prop)
1157+
{
1158+
void* ValuePtr = Prop->ContainerPtrToValuePtr<void>(NewTrait);
1159+
FString ValueStr;
1160+
if (Pair.Value->TryGetString(ValueStr))
1161+
{
1162+
Prop->ImportText_Direct(*ValueStr, ValuePtr, nullptr, PPF_None);
1163+
}
1164+
else if (Pair.Value->Type == EJson::Number)
1165+
{
1166+
// Handle numeric values
1167+
double NumVal = Pair.Value->AsNumber();
1168+
if (FFloatProperty* FloatProp = CastField<FFloatProperty>(Prop))
1169+
{
1170+
FloatProp->SetPropertyValue(ValuePtr, static_cast<float>(NumVal));
1171+
}
1172+
else if (FDoubleProperty* DoubleProp = CastField<FDoubleProperty>(Prop))
1173+
{
1174+
DoubleProp->SetPropertyValue(ValuePtr, NumVal);
1175+
}
1176+
else if (FIntProperty* IntProp = CastField<FIntProperty>(Prop))
1177+
{
1178+
IntProp->SetPropertyValue(ValuePtr, static_cast<int32>(NumVal));
1179+
}
1180+
}
1181+
else if (Pair.Value->Type == EJson::Boolean)
1182+
{
1183+
if (FBoolProperty* BoolProp = CastField<FBoolProperty>(Prop))
1184+
{
1185+
BoolProp->SetPropertyValue(ValuePtr, Pair.Value->AsBool());
1186+
}
1187+
}
1188+
}
1189+
}
1190+
}
1191+
1192+
// Add to array
1193+
int32 NewIndex = ArrayHelper.AddValue();
1194+
InnerObjProp->SetObjectPropertyValue(ArrayHelper.GetRawPtr(NewIndex), NewTrait);
1195+
1196+
// Mark dirty
1197+
Asset->MarkPackageDirty();
1198+
1199+
TSharedPtr<FJsonObject> Result = MakeShared<FJsonObject>();
1200+
Result->SetBoolField(TEXT("success"), true);
1201+
Result->SetStringField(TEXT("asset_path"), AssetPath);
1202+
Result->SetStringField(TEXT("trait_class"), TraitClass->GetName());
1203+
Result->SetNumberField(TEXT("trait_index"), NewIndex);
1204+
Result->SetNumberField(TEXT("total_traits"), ArrayHelper.Num());
1205+
return Result;
1206+
}

Source/UnrealMCPBridge/Private/EpicUnrealMCPBridge.cpp

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -672,7 +672,8 @@ FString UEpicUnrealMCPBridge::ExecuteCommand(
672672
CommandType == TEXT("list_data_assets") ||
673673
CommandType == TEXT("get_property_valid_types") ||
674674
CommandType == TEXT("search_class_paths") ||
675-
CommandType == TEXT("get_mass_config_traits"))
675+
CommandType == TEXT("get_mass_config_traits") ||
676+
CommandType == TEXT("add_mass_config_trait"))
676677
{
677678
ResultJson = DataAssetCommands->HandleCommand(CommandType, Params);
678679
}

Source/UnrealMCPBridge/Public/Commands/EpicUnrealMCPDataAssetCommands.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ class UNREALMCPBRIDGE_API FEpicUnrealMCPDataAssetCommands
2727
TSharedPtr<FJsonObject> HandleGetPropertyValidTypes(const TSharedPtr<FJsonObject>& Params);
2828
TSharedPtr<FJsonObject> HandleSearchClassPaths(const TSharedPtr<FJsonObject>& Params);
2929
TSharedPtr<FJsonObject> HandleGetMassConfigTraits(const TSharedPtr<FJsonObject>& Params);
30+
TSharedPtr<FJsonObject> HandleAddMassConfigTrait(const TSharedPtr<FJsonObject>& Params);
3031

3132
// Limited to UDataAsset subclasses. For any class use PU::ResolveAnyClass.
3233
static UClass* ResolveDataAssetClass(const FString& ClassName);

cli/cmd/data_assets.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,18 @@ ue-cli get_data_asset_properties --asset-path /Game/Data/DA_MyRecipe --filter "P
101101
{Name: "asset_path", Type: "string", Required: true, Help: "Content path to the Mass Entity config asset"},
102102
},
103103
},
104+
{
105+
Name: "add_mass_config_trait",
106+
Group: "dataassets",
107+
Short: "Add a single trait to a Mass Entity config",
108+
Long: "Safely appends a single trait to a MassEntityConfigAsset without replacing existing traits. Returns error if the trait already exists. Use for adding health, combat, or any trait to building configs without risking replicator or other complex trait data.",
109+
Example: `ue-cli add_mass_config_trait --asset-path /Game/Mass/DA_MinerConfig --trait-class /Script/Jiggify.MassBuildingHealthTrait --properties '{"MaxHealth":100}'`,
110+
Params: []ParamSpec{
111+
{Name: "asset_path", Type: "string", Required: true, Help: "Content path to the Mass Entity config asset"},
112+
{Name: "trait_class", Type: "string", Required: true, Help: "Full class path (e.g. /Script/Jiggify.MassBuildingHealthTrait)"},
113+
{Name: "properties", Type: "json", Help: "JSON object of property values to set on the new trait"},
114+
},
115+
},
104116
{
105117
Name: "list_data_assets",
106118
Group: "dataassets",

cli/npm/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "unrealcli",
3-
"version": "1.0.0",
3+
"version": "1.2.1",
44
"description": "CLI for controlling Unreal Engine 5 Editor — create materials, blueprints, spawn actors, modify assets. No Python, no MCP, no dependencies.",
55
"keywords": [
66
"unreal",

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "unrealmcp"
7-
version = "1.1.0"
7+
version = "1.2.1"
88
description = "MCP (Model Context Protocol) bridge for Unreal Engine 5 — lets AI assistants control the UE5 editor"
99
readme = "README.md"
1010
license = "MIT"

unrealmcp/tools/data_assets.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,33 @@ def get_mass_config_traits(asset_path: str) -> str:
202202
})
203203

204204

205+
@mcp.tool()
206+
def add_mass_config_trait(
207+
asset_path: str,
208+
trait_class: str,
209+
properties: dict | None = None,
210+
) -> str:
211+
"""Add a SINGLE trait to a Mass Entity Config without replacing existing traits.
212+
213+
Safe for configs with complex replicator settings — only appends, never replaces.
214+
Returns error if the trait already exists on the config.
215+
216+
Args:
217+
asset_path: Path to MassEntityConfigAsset (e.g. "/Game/.../DA_MinerMassEntityConfig_Mk1")
218+
trait_class: Full class path (e.g. "/Script/Jiggify.MassBuildingHealthTrait")
219+
properties: Optional dict of property values to set on the new trait.
220+
Supports floats, ints, bools, strings.
221+
Example: {"MaxHealth": 150, "Resistance_Fire": 0.2}
222+
"""
223+
params = {
224+
"asset_path": asset_path,
225+
"trait_class": trait_class,
226+
}
227+
if properties is not None:
228+
params["properties"] = properties
229+
return _call("add_mass_config_trait", params)
230+
231+
205232
@mcp.tool()
206233
def list_data_assets(
207234
path: str = "/Game",

0 commit comments

Comments
 (0)