ue-mcp 1.1.21 → 1.1.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +1 -1
  2. package/dist/plugin-cli.js +164 -3
  3. package/dist/plugin-cli.js.map +1 -1
  4. package/dist/tool-counts.json +9 -9
  5. package/dist/tools/asset.js +1 -0
  6. package/dist/tools/asset.js.map +1 -1
  7. package/dist/tools/audio.js +6 -1
  8. package/dist/tools/audio.js.map +1 -1
  9. package/dist/tools/editor.js +16 -5
  10. package/dist/tools/editor.js.map +1 -1
  11. package/dist/tools/gameplay.js +1 -1
  12. package/dist/tools/gameplay.js.map +1 -1
  13. package/dist/tools/landscape.js +4 -0
  14. package/dist/tools/landscape.js.map +1 -1
  15. package/dist/tools/level.js +7 -2
  16. package/dist/tools/level.js.map +1 -1
  17. package/dist/ue-mcp.default.yml +62 -8
  18. package/package.json +2 -2
  19. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/BridgeServer.cpp +6 -1
  20. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AssetHandlers.cpp +108 -0
  21. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AssetHandlers.h +3 -0
  22. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AudioHandlers.cpp +127 -17
  23. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AudioHandlers.h +2 -0
  24. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/EditorHandlers.cpp +258 -5
  25. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/EditorHandlers.h +7 -0
  26. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/GameplayHandlers_Input.cpp +75 -32
  27. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/LandscapeHandlers.cpp +100 -0
  28. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/LandscapeHandlers.h +4 -0
  29. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/LevelHandlers.cpp +79 -0
  30. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/LevelHandlers.h +2 -0
  31. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/LevelHandlers_Lights.cpp +15 -0
  32. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/PhysicsHandlers.cpp +7 -2
  33. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/WidgetHandlers.cpp +15 -2
  34. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/UE_MCP_Bridge.Build.cs +2 -0
  35. package/skills/ue-mcp-blueprint/SKILL.md +13 -0
  36. package/skills/ue-mcp-epic-routing/SKILL.md +37 -0
@@ -171,6 +171,9 @@ void FAssetHandlers::RegisterHandlers(FMCPHandlerRegistry& Registry)
171
171
  Registry.RegisterHandler(TEXT("delete_asset_batch"), &DeleteAssetBatch);
172
172
  Registry.RegisterHandler(TEXT("bulk_rename_assets"), &BulkRename);
173
173
  Registry.RegisterHandler(TEXT("create_data_asset"), &CreateDataAsset);
174
+ // #726: generic create-any-concrete-UObject-class asset (physical materials,
175
+ // curves, settings objects) - not restricted to UDataAsset subclasses.
176
+ Registry.RegisterHandler(TEXT("create_asset_by_class"), &CreateAssetByClass);
174
177
  Registry.RegisterHandler(TEXT("save_asset"), &SaveAsset);
175
178
  Registry.RegisterHandler(TEXT("save_all_dirty"), &SaveAllDirty);
176
179
  Registry.RegisterHandler(TEXT("list_textures"), &ListTextures);
@@ -1869,6 +1872,111 @@ TSharedPtr<FJsonValue> FAssetHandlers::CreateDataAsset(const TSharedPtr<FJsonObj
1869
1872
  return MCPResult(Result);
1870
1873
  }
1871
1874
 
1875
+ // #726: create an asset of any concrete UObject class. create_data_asset only
1876
+ // accepts UDataAsset subclasses, so "settings-object" classes (UPhysicalMaterial
1877
+ // subclasses, curves, etc.) had no native route and required execute_python with
1878
+ // a hand-picked UFactory. This resolves the class, creates the asset (IAssetTools
1879
+ // CreateAsset with a null factory NewObjects the exact class), applies optional
1880
+ // properties, and saves.
1881
+ TSharedPtr<FJsonValue> FAssetHandlers::CreateAssetByClass(const TSharedPtr<FJsonObject>& Params)
1882
+ {
1883
+ FString Name;
1884
+ if (auto Err = RequireString(Params, TEXT("name"), Name)) return Err;
1885
+ FString PackagePath = OptionalString(Params, TEXT("packagePath"), TEXT("/Game"));
1886
+ FString ClassName;
1887
+ if (auto Err = RequireStringAlt(Params, TEXT("className"), TEXT("class"), ClassName)) return Err;
1888
+
1889
+ // Resolve the class by path or (loaded) name - same resolution as create_data_asset.
1890
+ UClass* AssetClass = nullptr;
1891
+ if (ClassName.StartsWith(TEXT("/")))
1892
+ {
1893
+ AssetClass = LoadClass<UObject>(nullptr, *ClassName);
1894
+ if (!AssetClass) AssetClass = LoadObject<UClass>(nullptr, *ClassName);
1895
+ }
1896
+ if (!AssetClass)
1897
+ {
1898
+ FString Trimmed = ClassName;
1899
+ Trimmed.RemoveFromEnd(TEXT("_C"));
1900
+ for (TObjectIterator<UClass> It; It; ++It)
1901
+ {
1902
+ if (It->GetName() == Trimmed || It->GetName() == ClassName)
1903
+ {
1904
+ AssetClass = *It;
1905
+ break;
1906
+ }
1907
+ }
1908
+ }
1909
+ if (!AssetClass)
1910
+ {
1911
+ return MCPError(FString::Printf(TEXT("Class not found: %s (pass full /Script/Module.ClassName or a loaded class name)"), *ClassName));
1912
+ }
1913
+
1914
+ // Guard classes that cannot be standalone assets or need a specialized flow.
1915
+ if (AssetClass->HasAnyClassFlags(CLASS_Abstract))
1916
+ {
1917
+ return MCPError(FString::Printf(TEXT("Class %s is abstract and cannot be instantiated as an asset"), *ClassName));
1918
+ }
1919
+ if (AssetClass->IsChildOf(AActor::StaticClass()) || AssetClass->IsChildOf(UActorComponent::StaticClass()))
1920
+ {
1921
+ return MCPError(FString::Printf(TEXT("Class %s is an actor/component, not an asset - use level(place_actor) / level(add_component_to_actor)"), *ClassName));
1922
+ }
1923
+
1924
+ const FString FullPath = FString::Printf(TEXT("%s/%s.%s"), *PackagePath, *Name, *Name);
1925
+ const FString OnConflict = OptionalString(Params, TEXT("onConflict"), TEXT("skip"));
1926
+
1927
+ auto Created = MCPCreateAssetIdempotent<UObject>(Name, PackagePath, OnConflict, AssetClass->GetName(), AssetClass, nullptr);
1928
+ if (Created.EarlyReturn) return Created.EarlyReturn;
1929
+ UObject* NewAsset = Created.Asset;
1930
+
1931
+ // Optional properties (recursive JSON-to-property setter), mirroring create_data_asset.
1932
+ const TSharedPtr<FJsonObject>* PropsObj = nullptr;
1933
+ int32 SetCount = 0;
1934
+ TArray<FString> PropErrors;
1935
+ if (Params->TryGetObjectField(TEXT("properties"), PropsObj) && PropsObj && (*PropsObj).IsValid())
1936
+ {
1937
+ for (const auto& Pair : (*PropsObj)->Values)
1938
+ {
1939
+ FProperty* Prop = AssetClass->FindPropertyByName(FName(*Pair.Key));
1940
+ if (!Prop)
1941
+ {
1942
+ PropErrors.Add(FString::Printf(TEXT("Property not found: %s"), *Pair.Key));
1943
+ continue;
1944
+ }
1945
+ void* Addr = Prop->ContainerPtrToValuePtr<void>(NewAsset);
1946
+ FString SetErr;
1947
+ if (MCPJsonProperty::SetJsonOnProperty(Prop, Addr, Pair.Value, SetErr))
1948
+ {
1949
+ SetCount++;
1950
+ }
1951
+ else
1952
+ {
1953
+ PropErrors.Add(FString::Printf(TEXT("Failed to set %s: %s"), *Pair.Key, *SetErr));
1954
+ }
1955
+ }
1956
+ }
1957
+
1958
+ UEditorAssetLibrary::SaveAsset(FullPath);
1959
+
1960
+ auto Result = MCPSuccess();
1961
+ MCPSetCreated(Result);
1962
+ Result->SetStringField(TEXT("assetPath"), FullPath);
1963
+ Result->SetStringField(TEXT("name"), Name);
1964
+ Result->SetStringField(TEXT("className"), AssetClass->GetName());
1965
+ Result->SetNumberField(TEXT("propertiesSet"), SetCount);
1966
+ if (PropErrors.Num() > 0)
1967
+ {
1968
+ TArray<TSharedPtr<FJsonValue>> Errs;
1969
+ for (const FString& E : PropErrors) Errs.Add(MakeShared<FJsonValueString>(E));
1970
+ Result->SetArrayField(TEXT("propertyErrors"), Errs);
1971
+ }
1972
+
1973
+ TSharedPtr<FJsonObject> Payload = MakeShared<FJsonObject>();
1974
+ Payload->SetStringField(TEXT("assetPath"), FullPath);
1975
+ MCPSetRollback(Result, TEXT("delete_asset"), Payload);
1976
+
1977
+ return MCPResult(Result);
1978
+ }
1979
+
1872
1980
  TSharedPtr<FJsonValue> FAssetHandlers::SaveAsset(const TSharedPtr<FJsonObject>& Params)
1873
1981
  {
1874
1982
  FString AssetPath;
@@ -23,6 +23,9 @@ private:
23
23
  static TSharedPtr<FJsonValue> DeleteAssetBatch(const TSharedPtr<FJsonObject>& Params);
24
24
  static TSharedPtr<FJsonValue> BulkRename(const TSharedPtr<FJsonObject>& Params);
25
25
  static TSharedPtr<FJsonValue> CreateDataAsset(const TSharedPtr<FJsonObject>& Params);
26
+ // #726: create an asset of any concrete UObject class via its registered
27
+ // factory (or NewObject fallback), not just UDataAsset subclasses.
28
+ static TSharedPtr<FJsonValue> CreateAssetByClass(const TSharedPtr<FJsonObject>& Params);
26
29
  static TSharedPtr<FJsonValue> SaveAsset(const TSharedPtr<FJsonObject>& Params);
27
30
  // #429: bulk save of every dirty package - one-shot end-of-workflow flush.
28
31
  static TSharedPtr<FJsonValue> SaveAllDirty(const TSharedPtr<FJsonObject>& Params);
@@ -3,6 +3,7 @@
3
3
  #include "HandlerUtils.h"
4
4
  #include "HandlerAssetCreate.h"
5
5
  #include "AssetRegistry/AssetRegistryModule.h"
6
+ #include "AssetRegistry/ARFilter.h"
6
7
  #include "AssetToolsModule.h"
7
8
  #include "IAssetTools.h"
8
9
  #include "UObject/UObjectGlobals.h"
@@ -15,6 +16,7 @@
15
16
  #include "Factories/SoundCueFactoryNew.h"
16
17
  #include "AssetImportTask.h"
17
18
  #include "Misc/Paths.h"
19
+ #include "Misc/Base64.h"
18
20
  #include "Dom/JsonObject.h"
19
21
  #include "Dom/JsonValue.h"
20
22
  #include "Kismet/GameplayStatics.h"
@@ -25,6 +27,7 @@
25
27
  void FAudioHandlers::RegisterHandlers(FMCPHandlerRegistry& Registry)
26
28
  {
27
29
  Registry.RegisterHandler(TEXT("list_sound_assets"), &ListSoundAssets);
30
+ Registry.RegisterHandler(TEXT("extract_sound_wave_pcm"), &ExtractSoundWavePCM);
28
31
  Registry.RegisterHandler(TEXT("import_audio"), &ImportAudio);
29
32
  Registry.RegisterHandler(TEXT("create_sound_cue"), &CreateSoundCue);
30
33
  Registry.RegisterHandler(TEXT("create_metasound_source"), &CreateMetaSoundSource);
@@ -151,37 +154,144 @@ TSharedPtr<FJsonValue> FAudioHandlers::ListSoundAssets(const TSharedPtr<FJsonObj
151
154
  {
152
155
  auto Result = MCPSuccess();
153
156
 
154
- bool bRecursive = OptionalBool(Params, TEXT("recursive"), true);
157
+ // #730: the old implementation ignored `directory`, had no result cap, and
158
+ // serialized SoundWave + SoundCue + MetaSoundSource for the whole project in
159
+ // one response. On projects with hundreds of SoundWaves that response could
160
+ // exceed the WebSocket framing threshold and drop the bridge. Honor the
161
+ // directory, filter recursively via a single FARFilter query, and paginate.
162
+ const FString Directory = OptionalString(Params, TEXT("directory"), TEXT("/Game"));
163
+ const bool bRecursive = OptionalBool(Params, TEXT("recursive"), true);
164
+ int32 MaxResults = OptionalInt(Params, TEXT("maxResults"), 1000);
165
+ if (MaxResults <= 0) MaxResults = 1000;
166
+ int32 Offset = OptionalInt(Params, TEXT("offset"), 0);
167
+ if (Offset < 0) Offset = 0;
155
168
 
156
169
  IAssetRegistry& AssetRegistry = FModuleManager::LoadModuleChecked<FAssetRegistryModule>(TEXT("AssetRegistry")).Get();
157
170
 
158
- TArray<FTopLevelAssetPath> ClassPaths;
159
- ClassPaths.Add(FTopLevelAssetPath(TEXT("/Script/Engine"), TEXT("SoundWave")));
160
- ClassPaths.Add(FTopLevelAssetPath(TEXT("/Script/Engine"), TEXT("SoundCue")));
161
- ClassPaths.Add(FTopLevelAssetPath(TEXT("/Script/MetasoundEngine"), TEXT("MetaSoundSource")));
171
+ FARFilter Filter;
172
+ Filter.ClassPaths.Add(FTopLevelAssetPath(TEXT("/Script/Engine"), TEXT("SoundWave")));
173
+ Filter.ClassPaths.Add(FTopLevelAssetPath(TEXT("/Script/Engine"), TEXT("SoundCue")));
174
+ Filter.ClassPaths.Add(FTopLevelAssetPath(TEXT("/Script/MetasoundEngine"), TEXT("MetaSoundSource")));
175
+ Filter.bRecursiveClasses = true;
176
+ Filter.PackagePaths.Add(FName(*Directory));
177
+ Filter.bRecursivePaths = bRecursive;
162
178
 
179
+ TArray<FAssetData> AssetDataList;
180
+ AssetRegistry.GetAssets(Filter, AssetDataList);
181
+
182
+ // Stable ordering so pagination is deterministic across calls.
183
+ AssetDataList.Sort([](const FAssetData& A, const FAssetData& B)
184
+ {
185
+ return A.GetObjectPathString() < B.GetObjectPathString();
186
+ });
187
+
188
+ const int32 Total = AssetDataList.Num();
163
189
  TArray<TSharedPtr<FJsonValue>> AssetsArray;
190
+ for (int32 Index = Offset; Index < Total && AssetsArray.Num() < MaxResults; ++Index)
191
+ {
192
+ const FAssetData& AssetData = AssetDataList[Index];
193
+ TSharedPtr<FJsonObject> AssetObj = MakeShared<FJsonObject>();
194
+ AssetObj->SetStringField(TEXT("name"), AssetData.AssetName.ToString());
195
+ AssetObj->SetStringField(TEXT("path"), AssetData.GetObjectPathString());
196
+ AssetObj->SetStringField(TEXT("class"), AssetData.AssetClassPath.GetAssetName().ToString());
197
+ AssetObj->SetStringField(TEXT("packagePath"), AssetData.PackagePath.ToString());
198
+ AssetsArray.Add(MakeShared<FJsonValueObject>(AssetObj));
199
+ }
164
200
 
165
- for (const FTopLevelAssetPath& ClassPath : ClassPaths)
201
+ const int32 NextOffset = Offset + AssetsArray.Num();
202
+ Result->SetArrayField(TEXT("assets"), AssetsArray);
203
+ Result->SetNumberField(TEXT("count"), AssetsArray.Num());
204
+ Result->SetNumberField(TEXT("total"), Total);
205
+ Result->SetNumberField(TEXT("offset"), Offset);
206
+ Result->SetNumberField(TEXT("maxResults"), MaxResults);
207
+ Result->SetBoolField(TEXT("hasMore"), NextOffset < Total);
208
+ if (NextOffset < Total)
166
209
  {
167
- TArray<FAssetData> AssetDataList;
168
- AssetRegistry.GetAssetsByClass(ClassPath, AssetDataList, bRecursive);
210
+ Result->SetNumberField(TEXT("nextOffset"), NextOffset);
211
+ }
212
+ Result->SetStringField(TEXT("directory"), Directory);
213
+
214
+ return MCPResult(Result);
215
+ }
216
+
217
+ // #729: decode a USoundWave's imported audio to in-memory PCM. UE Python does
218
+ // not expose USoundWave::GetImportedSoundWaveData, so a semantic-search pipeline
219
+ // (CLAP etc.) previously had no way to reach the samples without relying on the
220
+ // original import file, which may have moved. This returns interleaved signed
221
+ // 16-bit PCM, base64-encoded, plus the format metadata needed to feed a model.
222
+ TSharedPtr<FJsonValue> FAudioHandlers::ExtractSoundWavePCM(const TSharedPtr<FJsonObject>& Params)
223
+ {
224
+ FString SoundPath;
225
+ if (auto Err = RequireString(Params, TEXT("soundPath"), SoundPath)) return Err;
226
+
227
+ USoundWave* Wave = LoadObject<USoundWave>(nullptr, *SoundPath);
228
+ if (!Wave)
229
+ {
230
+ return MCPError(FString::Printf(TEXT("SoundWave not found: %s"), *SoundPath));
231
+ }
169
232
 
170
- for (const FAssetData& AssetData : AssetDataList)
233
+ #if WITH_EDITOR
234
+ TArray<uint8> RawPCM;
235
+ uint32 SampleRate = 0;
236
+ uint16 NumChannels = 0;
237
+ if (!Wave->GetImportedSoundWaveData(RawPCM, SampleRate, NumChannels)
238
+ || RawPCM.Num() == 0 || SampleRate == 0 || NumChannels == 0)
239
+ {
240
+ return MCPError(TEXT("Failed to decode imported SoundWave data (no editor source data available for this asset)"));
241
+ }
242
+
243
+ // RawPCM is interleaved signed 16-bit little-endian across NumChannels.
244
+ int32 TotalFrames = (RawPCM.Num() / (int32)sizeof(int16)) / NumChannels;
245
+
246
+ // Optional decode window so callers can bound the response size (CLAP-style
247
+ // pipelines only need a few seconds). Default is the whole asset.
248
+ const double MaxSeconds = OptionalNumber(Params, TEXT("maxSeconds"), 0.0);
249
+ if (MaxSeconds > 0.0)
250
+ {
251
+ const int32 FrameCap = FMath::Clamp(FMath::FloorToInt(MaxSeconds * (double)SampleRate), 0, TotalFrames);
252
+ TotalFrames = FrameCap;
253
+ }
254
+
255
+ const bool bDownmix = OptionalBool(Params, TEXT("downmixMono"), false);
256
+ const int16* Samples = reinterpret_cast<const int16*>(RawPCM.GetData());
257
+
258
+ TArray<uint8> OutBytes;
259
+ int32 OutChannels = NumChannels;
260
+ if (bDownmix && NumChannels > 1)
261
+ {
262
+ OutChannels = 1;
263
+ OutBytes.SetNumUninitialized(TotalFrames * (int32)sizeof(int16));
264
+ int16* Dst = reinterpret_cast<int16*>(OutBytes.GetData());
265
+ for (int32 Frame = 0; Frame < TotalFrames; ++Frame)
171
266
  {
172
- TSharedPtr<FJsonObject> AssetObj = MakeShared<FJsonObject>();
173
- AssetObj->SetStringField(TEXT("name"), AssetData.AssetName.ToString());
174
- AssetObj->SetStringField(TEXT("path"), AssetData.GetObjectPathString());
175
- AssetObj->SetStringField(TEXT("class"), AssetData.AssetClassPath.GetAssetName().ToString());
176
- AssetObj->SetStringField(TEXT("packagePath"), AssetData.PackagePath.ToString());
177
- AssetsArray.Add(MakeShared<FJsonValueObject>(AssetObj));
267
+ int32 Acc = 0;
268
+ for (int32 Ch = 0; Ch < NumChannels; ++Ch)
269
+ {
270
+ Acc += Samples[Frame * NumChannels + Ch];
271
+ }
272
+ Dst[Frame] = static_cast<int16>(Acc / NumChannels);
178
273
  }
179
274
  }
275
+ else
276
+ {
277
+ const int32 ByteCount = TotalFrames * NumChannels * (int32)sizeof(int16);
278
+ OutBytes.Append(RawPCM.GetData(), ByteCount);
279
+ }
180
280
 
181
- Result->SetArrayField(TEXT("assets"), AssetsArray);
182
- Result->SetNumberField(TEXT("count"), AssetsArray.Num());
281
+ const FString Base64 = FBase64::Encode(OutBytes);
183
282
 
283
+ auto Result = MCPSuccess();
284
+ Result->SetStringField(TEXT("soundPath"), SoundPath);
285
+ Result->SetNumberField(TEXT("sampleRate"), static_cast<double>(SampleRate));
286
+ Result->SetNumberField(TEXT("numChannels"), static_cast<double>(OutChannels));
287
+ Result->SetNumberField(TEXT("numFrames"), static_cast<double>(TotalFrames));
288
+ Result->SetNumberField(TEXT("durationSeconds"), SampleRate > 0 ? static_cast<double>(TotalFrames) / static_cast<double>(SampleRate) : 0.0);
289
+ Result->SetStringField(TEXT("format"), TEXT("pcm_s16le"));
290
+ Result->SetStringField(TEXT("pcmBase64"), Base64);
184
291
  return MCPResult(Result);
292
+ #else
293
+ return MCPError(TEXT("extract_sound_wave_pcm requires an editor build"));
294
+ #endif
185
295
  }
186
296
 
187
297
  TSharedPtr<FJsonValue> FAudioHandlers::CreateSoundCue(const TSharedPtr<FJsonObject>& Params)
@@ -12,6 +12,8 @@ public:
12
12
  private:
13
13
  // ── Assets + playback ──────────────────────────────────────────────
14
14
  static TSharedPtr<FJsonValue> ListSoundAssets(const TSharedPtr<FJsonObject>& Params);
15
+ // #729: decode a USoundWave's imported audio to in-memory PCM for semantic search.
16
+ static TSharedPtr<FJsonValue> ExtractSoundWavePCM(const TSharedPtr<FJsonObject>& Params);
15
17
  // #664: import a WAV/OGG file as a USoundWave asset.
16
18
  static TSharedPtr<FJsonValue> ImportAudio(const TSharedPtr<FJsonObject>& Params);
17
19
  static TSharedPtr<FJsonValue> CreateSoundCue(const TSharedPtr<FJsonObject>& Params);
@@ -17,6 +17,12 @@
17
17
  #include "Dom/JsonObject.h"
18
18
  #include "Dom/JsonValue.h"
19
19
  #include "IPythonScriptPlugin.h"
20
+ #include "LevelSequence.h"
21
+ #include "LevelSequenceEditorBlueprintLibrary.h"
22
+ #include "Framework/Docking/TabManager.h"
23
+ #include "Widgets/Docking/SDockTab.h"
24
+ #include "ISettingsModule.h"
25
+ #include "Modules/ModuleManager.h"
20
26
  #include "Misc/ConfigCacheIni.h"
21
27
  #include "Misc/ConfigContext.h"
22
28
  #include "Misc/Paths.h"
@@ -24,6 +30,7 @@
24
30
  #include "Misc/FileHelper.h"
25
31
  #include "LevelEditorViewport.h"
26
32
  #include "UnrealClient.h"
33
+ #include "Engine/GameViewportClient.h"
27
34
  #include "ContentStreaming.h"
28
35
  #include "RenderingThread.h"
29
36
  #include "Misc/AutomationTest.h"
@@ -211,6 +218,13 @@ void FEditorHandlers::RegisterHandlers(FMCPHandlerRegistry& Registry)
211
218
  // Kismet, anything user-defined). Pair with editor.invoke_function to
212
219
  // drive GeometryScript ops from MCP without hand-writing each handler.
213
220
  Registry.RegisterHandler(TEXT("list_function_libraries"), &ListFunctionLibraries);
221
+ // #718: close the open Level Sequence editor before destructive actor ops.
222
+ Registry.RegisterHandler(TEXT("close_sequence"), &CloseSequence);
223
+ // #719: purge cached embedded-Python modules by prefix for tool-dev iteration.
224
+ Registry.RegisterHandler(TEXT("purge_python_modules"), &PurgePythonModules);
225
+ // #727: open a registered editor tab / Project Settings viewer for visual evidence.
226
+ Registry.RegisterHandler(TEXT("open_tab"), &OpenTab);
227
+ Registry.RegisterHandler(TEXT("open_settings"), &OpenSettings);
214
228
  }
215
229
 
216
230
  TSharedPtr<FJsonValue> FEditorHandlers::ExecuteCommand(const TSharedPtr<FJsonObject>& Params)
@@ -245,9 +259,35 @@ TSharedPtr<FJsonValue> FEditorHandlers::ExecutePython(const TSharedPtr<FJsonObje
245
259
 
246
260
  bool bSuccess = PythonPlugin->ExecPythonCommandEx(PythonCommand);
247
261
 
262
+ // #732: a first-class result channel. In ExecuteFile mode CommandResult is
263
+ // normally empty and a top-level `return` is illegal, so scripts were forced
264
+ // to use print() as transport - mixing application data with diagnostics and
265
+ // duplicating it across log_output/output. When the caller names a
266
+ // resultVariable, evaluate it in the Public (__main__) scope the script just
267
+ // ran in and surface it as `result`, leaving print()/log as diagnostics only.
268
+ FString ResultText = PythonCommand.CommandResult;
269
+ const FString ResultVariable = OptionalString(Params, TEXT("resultVariable"));
270
+ bool bResultVariableResolved = false;
271
+ if (bSuccess && !ResultVariable.IsEmpty())
272
+ {
273
+ FPythonCommandEx EvalCommand;
274
+ EvalCommand.Command = ResultVariable;
275
+ EvalCommand.ExecutionMode = EPythonCommandExecutionMode::EvaluateStatement;
276
+ EvalCommand.FileExecutionScope = EPythonFileExecutionScope::Public;
277
+ if (PythonPlugin->ExecPythonCommandEx(EvalCommand))
278
+ {
279
+ ResultText = EvalCommand.CommandResult;
280
+ bResultVariableResolved = true;
281
+ }
282
+ }
283
+
248
284
  TSharedPtr<FJsonObject> Result = MakeShared<FJsonObject>();
249
285
  Result->SetBoolField(TEXT("success"), bSuccess);
250
- Result->SetStringField(TEXT("result"), PythonCommand.CommandResult);
286
+ Result->SetStringField(TEXT("result"), ResultText);
287
+ if (!ResultVariable.IsEmpty())
288
+ {
289
+ Result->SetBoolField(TEXT("resultVariableResolved"), bResultVariableResolved);
290
+ }
251
291
 
252
292
  TArray<TSharedPtr<FJsonValue>> LogArray;
253
293
  for (const FPythonLogOutputEntry& Entry : PythonCommand.LogOutput)
@@ -313,10 +353,32 @@ TSharedPtr<FJsonValue> FEditorHandlers::RunPythonFile(const TSharedPtr<FJsonObje
313
353
 
314
354
  bool bSuccess = PythonPlugin->ExecPythonCommandEx(PythonCommand);
315
355
 
356
+ // #732: same first-class result channel as execute_python. The file runs in
357
+ // the Public (__main__) scope, so a named resultVariable can be read back.
358
+ FString ResultText = PythonCommand.CommandResult;
359
+ const FString ResultVariable = OptionalString(Params, TEXT("resultVariable"));
360
+ bool bResultVariableResolved = false;
361
+ if (bSuccess && !ResultVariable.IsEmpty())
362
+ {
363
+ FPythonCommandEx EvalCommand;
364
+ EvalCommand.Command = ResultVariable;
365
+ EvalCommand.ExecutionMode = EPythonCommandExecutionMode::EvaluateStatement;
366
+ EvalCommand.FileExecutionScope = EPythonFileExecutionScope::Public;
367
+ if (PythonPlugin->ExecPythonCommandEx(EvalCommand))
368
+ {
369
+ ResultText = EvalCommand.CommandResult;
370
+ bResultVariableResolved = true;
371
+ }
372
+ }
373
+
316
374
  TSharedPtr<FJsonObject> Result = MakeShared<FJsonObject>();
317
375
  Result->SetBoolField(TEXT("success"), bSuccess);
318
376
  Result->SetStringField(TEXT("path"), FilePath);
319
- Result->SetStringField(TEXT("result"), PythonCommand.CommandResult);
377
+ Result->SetStringField(TEXT("result"), ResultText);
378
+ if (!ResultVariable.IsEmpty())
379
+ {
380
+ Result->SetBoolField(TEXT("resultVariableResolved"), bResultVariableResolved);
381
+ }
320
382
 
321
383
  TArray<TSharedPtr<FJsonValue>> LogArray;
322
384
  FString CombinedOutput;
@@ -335,6 +397,150 @@ TSharedPtr<FJsonValue> FEditorHandlers::RunPythonFile(const TSharedPtr<FJsonObje
335
397
  return MCPResult(Result);
336
398
  }
337
399
 
400
+ // #719: UE's embedded Python caches imported modules for the whole editor
401
+ // session, so after editing a pipeline tool on disk the editor keeps running
402
+ // stale code until the modules are purged from sys.modules. sys.modules is
403
+ // Python-runtime state with no C++ accessor, so the interpreter (a hard plugin
404
+ // dependency) is the correct owner to drive. We emit per-module markers rather
405
+ // than rely on eval repr/str ambiguity, then rebuild the list in C++.
406
+ TSharedPtr<FJsonValue> FEditorHandlers::PurgePythonModules(const TSharedPtr<FJsonObject>& Params)
407
+ {
408
+ FString Prefix;
409
+ if (auto Err = RequireString(Params, TEXT("prefix"), Prefix)) return Err;
410
+ if (Prefix.TrimStartAndEnd().IsEmpty())
411
+ {
412
+ return MCPError(TEXT("'prefix' must be non-empty (an empty prefix would purge every module)"));
413
+ }
414
+
415
+ IPythonScriptPlugin* PythonPlugin = IPythonScriptPlugin::Get();
416
+ if (!PythonPlugin || !PythonPlugin->IsPythonAvailable())
417
+ {
418
+ return MCPError(TEXT("Python scripting is not available"));
419
+ }
420
+
421
+ // Escape the prefix into a Python single-quoted literal.
422
+ FString Escaped = Prefix.Replace(TEXT("\\"), TEXT("\\\\")).Replace(TEXT("'"), TEXT("\\'"));
423
+
424
+ FString Code;
425
+ Code += TEXT("import sys as __mcp_sys\n");
426
+ Code += FString::Printf(TEXT("__mcp_names = [__m for __m in list(__mcp_sys.modules) if __m.startswith('%s')]\n"), *Escaped);
427
+ Code += TEXT("for __m in __mcp_names:\n");
428
+ Code += TEXT(" del __mcp_sys.modules[__m]\n");
429
+ Code += TEXT(" print('MCP_PURGED_ITEM:' + __m)\n");
430
+
431
+ FPythonCommandEx PythonCommand;
432
+ PythonCommand.Command = Code;
433
+ PythonCommand.ExecutionMode = EPythonCommandExecutionMode::ExecuteFile;
434
+ PythonCommand.FileExecutionScope = EPythonFileExecutionScope::Public;
435
+ const bool bSuccess = PythonPlugin->ExecPythonCommandEx(PythonCommand);
436
+ if (!bSuccess)
437
+ {
438
+ return MCPError(TEXT("Failed to purge Python modules (interpreter error)"));
439
+ }
440
+
441
+ TArray<TSharedPtr<FJsonValue>> Purged;
442
+ const FString Marker = TEXT("MCP_PURGED_ITEM:");
443
+ for (const FPythonLogOutputEntry& Entry : PythonCommand.LogOutput)
444
+ {
445
+ FString Line = Entry.Output;
446
+ int32 Idx = Line.Find(Marker);
447
+ if (Idx != INDEX_NONE)
448
+ {
449
+ FString Name = Line.RightChop(Idx + Marker.Len()).TrimStartAndEnd();
450
+ if (!Name.IsEmpty())
451
+ {
452
+ Purged.Add(MakeShared<FJsonValueString>(Name));
453
+ }
454
+ }
455
+ }
456
+
457
+ auto Result = MCPSuccess();
458
+ Result->SetStringField(TEXT("prefix"), Prefix);
459
+ Result->SetArrayField(TEXT("purged"), Purged);
460
+ Result->SetNumberField(TEXT("count"), Purged.Num());
461
+ return MCPResult(Result);
462
+ }
463
+
464
+ // #718: close the currently open Level Sequence editor. Open sequences
465
+ // re-resolve possessables by name during actor destruction, which can mis-bind
466
+ // or destabilize the editor, so bulk actor ops want the sequencer closed first.
467
+ TSharedPtr<FJsonValue> FEditorHandlers::CloseSequence(const TSharedPtr<FJsonObject>& /*Params*/)
468
+ {
469
+ ULevelSequence* Current = ULevelSequenceEditorBlueprintLibrary::GetCurrentLevelSequence();
470
+ const bool bWasOpen = Current != nullptr;
471
+ const FString OpenPath = bWasOpen ? Current->GetPathName() : FString();
472
+
473
+ ULevelSequenceEditorBlueprintLibrary::CloseLevelSequence();
474
+
475
+ auto Result = MCPSuccess();
476
+ Result->SetBoolField(TEXT("wasOpen"), bWasOpen);
477
+ if (bWasOpen)
478
+ {
479
+ Result->SetStringField(TEXT("closedSequence"), OpenPath);
480
+ }
481
+ return MCPResult(Result);
482
+ }
483
+
484
+ // #727: open a registered editor tab by ID (e.g. "ProjectSettings", "OutputLog")
485
+ // so an agent can screenshot editor UI as evidence.
486
+ TSharedPtr<FJsonValue> FEditorHandlers::OpenTab(const TSharedPtr<FJsonObject>& Params)
487
+ {
488
+ FString TabId;
489
+ if (auto Err = RequireString(Params, TEXT("tabId"), TabId)) return Err;
490
+
491
+ TSharedPtr<SDockTab> Tab = FGlobalTabmanager::Get()->TryInvokeTab(FTabId(*TabId));
492
+
493
+ auto Result = MCPSuccess();
494
+ Result->SetStringField(TEXT("tabId"), TabId);
495
+ Result->SetBoolField(TEXT("opened"), Tab.IsValid());
496
+ if (!Tab.IsValid())
497
+ {
498
+ Result->SetBoolField(TEXT("success"), false);
499
+ Result->SetStringField(TEXT("error"), FString::Printf(TEXT("No registered tab with id '%s' (try 'ProjectSettings', 'OutputLog', 'ContentBrowserTab1', ...)"), *TabId));
500
+ }
501
+ return MCPResult(Result);
502
+ }
503
+
504
+ // #727: open (and navigate) a settings viewer - Project Settings / Editor
505
+ // Preferences. section may be a bare section name (with category) or a dotted
506
+ // "Category.Section" pair for convenience.
507
+ TSharedPtr<FJsonValue> FEditorHandlers::OpenSettings(const TSharedPtr<FJsonObject>& Params)
508
+ {
509
+ FString Container = OptionalString(Params, TEXT("container"), TEXT("Project"));
510
+ FString Category = OptionalString(Params, TEXT("category"));
511
+ FString Section = OptionalString(Params, TEXT("section"));
512
+
513
+ // Accept a combined "Category.Section" in `section` when `category` is absent.
514
+ if (Category.IsEmpty() && Section.Contains(TEXT(".")))
515
+ {
516
+ FString Left, Right;
517
+ if (Section.Split(TEXT("."), &Left, &Right))
518
+ {
519
+ Category = Left;
520
+ Section = Right;
521
+ }
522
+ }
523
+
524
+ ISettingsModule* SettingsModule = FModuleManager::GetModulePtr<ISettingsModule>("Settings");
525
+ if (!SettingsModule)
526
+ {
527
+ return MCPError(TEXT("Settings module not available"));
528
+ }
529
+
530
+ // Make sure the viewer tab exists, then show the requested section.
531
+ FGlobalTabmanager::Get()->TryInvokeTab(FTabId(Container == TEXT("Editor") ? TEXT("EditorSettings") : TEXT("ProjectSettings")));
532
+ if (!Category.IsEmpty())
533
+ {
534
+ SettingsModule->ShowViewer(FName(*Container), FName(*Category), FName(*Section));
535
+ }
536
+
537
+ auto Result = MCPSuccess();
538
+ Result->SetStringField(TEXT("container"), Container);
539
+ Result->SetStringField(TEXT("category"), Category);
540
+ Result->SetStringField(TEXT("section"), Section);
541
+ return MCPResult(Result);
542
+ }
543
+
338
544
  TSharedPtr<FJsonValue> FEditorHandlers::SetProperty(const TSharedPtr<FJsonObject>& Params)
339
545
  {
340
546
  // #221/#230: TS schema documents `objectPath` but the dispatcher only
@@ -1042,9 +1248,37 @@ TSharedPtr<FJsonValue> FEditorHandlers::CaptureScreenshot(const TSharedPtr<FJson
1042
1248
 
1043
1249
  if (bUsePie && PieWorld)
1044
1250
  {
1251
+ // #724: HighResShot renders the PIE world offscreen and (a) strips the
1252
+ // debug canvas (AddOnScreenDebugMessage overlays) and (b) in
1253
+ // Play-in-New-Window did not reliably resolve to the PIE game window.
1254
+ // Capture the actual PIE game viewport with a normal screenshot request
1255
+ // and bShowUI=true, so we get exactly what the player sees - HUD and the
1256
+ // on-screen debug canvas included. The running game viewport consumes the
1257
+ // pending request on its next Draw, so this targets the PIE window even
1258
+ // in new-window mode. Fall back to HighResShot only if no game viewport.
1259
+ FString FullPath = Filename;
1260
+ if (FPaths::IsRelative(Filename))
1261
+ {
1262
+ FullPath = FPaths::Combine(FPaths::ProjectSavedDir(), TEXT("Screenshots"), Filename);
1263
+ }
1264
+
1265
+ UGameViewportClient* GameViewport = PieWorld->GetGameViewport();
1266
+ if (GameViewport && GameViewport->Viewport)
1267
+ {
1268
+ FScreenshotRequest::RequestScreenshot(FullPath, /*bShowUI=*/true, /*bAddFilenameSuffix=*/false);
1269
+ GameViewport->Viewport->Invalidate();
1270
+
1271
+ auto Result = MCPSuccess();
1272
+ Result->SetStringField(TEXT("filename"), FullPath);
1273
+ Result->SetStringField(TEXT("target"), TEXT("pie"));
1274
+ Result->SetBoolField(TEXT("includesDebugCanvas"), true);
1275
+ Result->SetStringField(TEXT("note"), TEXT("PIE game-viewport screenshot queued (UI + on-screen debug canvas included); written asynchronously."));
1276
+ return MCPResult(Result);
1277
+ }
1278
+
1279
+ // Fallback: no resolvable game viewport (unusual) - dispatch HighResShot.
1045
1280
  int32 Width = OptionalInt(Params, TEXT("width"), 1920);
1046
1281
  int32 Height = OptionalInt(Params, TEXT("height"), 1080);
1047
- // Some callers pass a single 'resolution' (long edge); honour it as width.
1048
1282
  double ResolutionScalar = 0.0;
1049
1283
  if (Params->TryGetNumberField(TEXT("resolution"), ResolutionScalar) && ResolutionScalar > 0)
1050
1284
  {
@@ -1057,7 +1291,8 @@ TSharedPtr<FJsonValue> FEditorHandlers::CaptureScreenshot(const TSharedPtr<FJson
1057
1291
  Result->SetStringField(TEXT("filename"), Filename);
1058
1292
  Result->SetStringField(TEXT("target"), TEXT("pie"));
1059
1293
  Result->SetStringField(TEXT("consoleCommand"), ConsoleCmd);
1060
- Result->SetStringField(TEXT("note"), TEXT("HighResShot dispatched into PIE world; output lands in Saved/Screenshots/<map>/."));
1294
+ Result->SetBoolField(TEXT("includesDebugCanvas"), false);
1295
+ Result->SetStringField(TEXT("note"), TEXT("No game viewport resolved; HighResShot dispatched (debug canvas not captured). Output in Saved/Screenshots/<map>/."));
1061
1296
  return MCPResult(Result);
1062
1297
  }
1063
1298
 
@@ -1509,7 +1744,25 @@ TSharedPtr<FJsonValue> FEditorHandlers::OpenAsset(const TSharedPtr<FJsonObject>&
1509
1744
 
1510
1745
  TSharedPtr<FJsonValue> FEditorHandlers::RunStatCommand(const TSharedPtr<FJsonObject>& Params)
1511
1746
  {
1512
- FString Command = OptionalString(Params, TEXT("command"), TEXT("stat fps"));
1747
+ // #722: callers naturally pass the stat name (e.g. name="unit"); the old
1748
+ // handler only read "command" and silently defaulted to "stat fps" when the
1749
+ // stat was passed as "name", so "unit" ran the FPS counter. Accept either:
1750
+ // an explicit full "command", or a bare stat "name" that we prefix.
1751
+ FString Command = OptionalString(Params, TEXT("command"));
1752
+ if (Command.IsEmpty())
1753
+ {
1754
+ const FString StatName = OptionalString(Params, TEXT("name"));
1755
+ if (!StatName.IsEmpty())
1756
+ {
1757
+ Command = StatName.StartsWith(TEXT("stat "), ESearchCase::IgnoreCase)
1758
+ ? StatName
1759
+ : FString::Printf(TEXT("stat %s"), *StatName);
1760
+ }
1761
+ else
1762
+ {
1763
+ Command = TEXT("stat fps");
1764
+ }
1765
+ }
1513
1766
 
1514
1767
  REQUIRE_EDITOR_WORLD(World);
1515
1768
 
@@ -115,6 +115,13 @@ private:
115
115
  static TSharedPtr<FJsonValue> ExecutePython(const TSharedPtr<FJsonObject>& Params);
116
116
  // #142: run a Python file with __file__/__name__ context populated
117
117
  static TSharedPtr<FJsonValue> RunPythonFile(const TSharedPtr<FJsonObject>& Params);
118
+ // #719: purge cached embedded-Python modules by prefix (tool-dev iteration)
119
+ static TSharedPtr<FJsonValue> PurgePythonModules(const TSharedPtr<FJsonObject>& Params);
120
+ // #718: close the currently open Level Sequence editor
121
+ static TSharedPtr<FJsonValue> CloseSequence(const TSharedPtr<FJsonObject>& Params);
122
+ // #727: open a registered editor tab / the Project Settings viewer
123
+ static TSharedPtr<FJsonValue> OpenTab(const TSharedPtr<FJsonObject>& Params);
124
+ static TSharedPtr<FJsonValue> OpenSettings(const TSharedPtr<FJsonObject>& Params);
118
125
  static TSharedPtr<FJsonValue> SetProperty(const TSharedPtr<FJsonObject>& Params);
119
126
  static TSharedPtr<FJsonValue> GetProperty(const TSharedPtr<FJsonObject>& Params);
120
127
  static TSharedPtr<FJsonValue> DescribeObject(const TSharedPtr<FJsonObject>& Params);