Skip to content

Commit f9bc023

Browse files
committed
Niagara: full stack-input authoring + graph introspection loop
Brings the Niagara MCP surface to parity with what the editor UI actually lets you do. You can now read, mutate, and replicate any module input binding at any nesting depth without guessing engine API or hardcoded asset paths — and the same 96 commands work from Python MCP, Go CLI, or direct C++ dispatcher. Graph introspection (new NiagaraGraphIntrospection.cpp) - get_niagara_graph_nodes: list every node with verbosity control (summary / connections / full) + type_filter + name_filter. Three resolver modes: scratch pad (system+module), emitter stack graph (system+emitter+script_usage), or standalone script asset. - get_niagara_node_info: deep single-node inspect by index/class/GUID with pin layout, connections, and node-type-specific fields (op_name, function_script, hlsl_preview, input_name/type). - trace_niagara_connection: BFS upstream/downstream with pin_name filter. - validate_niagara_graph: orphan/dead-end/missing-input classifier, skips +Add placeholder pins so no false positives. Scratch pad apply + script properties (new NiagaraScratchPadApply.cpp) - apply_niagara_scratch_pad / apply_and_save_niagara_scratch_pad wrap FNiagaraScratchPadScriptViewModel::ApplyChanges[AndSave] — the actual Apply / Apply & Save buttons. - get/set_niagara_script_properties covers every FVersionedNiagaraScriptData field including TArray<FName> (ProvidedDependencies) and TArray<FNiagaraModuleDependency> (RequiredDependencies). Explicit per-FProperty-type reader avoids an FText reflection crash the generic walker used to hit. Script parameters + node creation (new NiagaraScriptParams.cpp) - list/add/remove/rename_niagara_script_parameter with automatic cascade to Map Get / Map Set pins on remove. Output-param adds dispatch PostEditChangeProperty on the Output node so the canvas refreshes immediately instead of needing a name-field tickle. - add_niagara_graph_node supports Op, FunctionCall, DataInterfaceFunction, ParameterMapGet, ParameterMapSet, Reroute, Input. DI member functions (array.Length etc) use UNiagaraDataInterface::GetFunctionSignatures and set UNiagaraNodeFunctionCall::Signature directly — FunctionScript stays null, same as the editor's right-click "Functions" submenu. - delete_niagara_graph_node by index or GUID, mirrors on asset + edit-copy. Stack input binding loop (new NiagaraStackBinding.cpp) - get_niagara_module_input_binding resolves Default / Local / Linked / Dynamic / Data / Expression mode for every input and recurses into dynamic-input children. One call returns the full tree — no separate walk of override pins. - clear_niagara_module_input resets any input to default, top-level or nested via dot-path (e.g. "Spawn Count.Position Array"). Replicates FNiagaraStackGraphUtilities::RemoveNodesForStackFunctionInputOverridePin (non-exported) using exported primitives. - list_niagara_input_source_menu reproduces the stack-UI source dropdown — all DynamicInput-usage scripts from AssetRegistry plus scratch-pad DIs on the same system (tagged source: "scratch_pad"), plus link parameters from Engine / Particles / User namespaces via FNiagaraConstants. Stack discovery + reverse lookup (new NiagaraStackDiscovery.cpp) - find_niagara_scratch_pad_usage scans every system + emitter stack graph for FunctionCall nodes referencing a scratch pad, so you can answer "what uses this DI?" in one call. - resolve_niagara_built_in_dynamic_input replaces hardcoded engine paths (/Niagara/Modules/DynamicInputs/UniformRangedFloat — which moves between UE versions) with live AssetRegistry scan. - list_niagara_data_interface_functions enumerates DI member functions via UNiagaraDataInterface::GetFunctionSignatures so the AI can pick a real function_name before spawning a DataInterfaceFunction node. Nested path resolver (new NiagaraStackPathResolver.h) - Shared DescendNestedPath helper walks a dot-path like "Spawn Count.Position Array" through dynamic-input function-call chains. - Wired into set_niagara_dynamic_input, link_niagara_parameter, and set_niagara_module_input so every setter now accepts arbitrary nesting. Nested literal setter - set_niagara_module_input on a dot-path routes to GetOrCreateStackFunctionInputOverridePin + DefaultValue write, the actual path the editor uses for local values. Top-level inputs whose source is parameter_map_input auto-delegate to the rapid-iteration setter so you don't get "input not found" errors for Module.* aliased inputs like SpawnPerFrame's Spawn Probability. Dynamic input template resolution - set_niagara_dynamic_input for random_range / uniform_random types now falls back to FNiagaraEditorUtilities::GetFilteredScriptAssets filtered on Usage=DynamicInput + name substring if the canonical engine path doesn't resolve. Handles V2 asset-moves across UE versions. Emitter stack graph introspection - get_niagara_graph_nodes accepts emitter_name + script_usage so you can read EmitterUpdateScript / ParticleSpawnScript graphs directly — the actual graphs where stack modules appear as UNiagaraNodeFunctionCall nodes with visible override pins. Renderer binding reader - get_niagara_renderer_info now returns each binding with binding_name (the UPROPERTY name that set_niagara_renderer_binding accepts) + bound_variable + bound_variable_type + dataset_variable. Uses reflection to map binding pointers to their UPROPERTY names. Describe-type fallback - describe_niagara_type falls back to UEnum / UScriptStruct reflection when the name isn't in FNiagaraTypeRegistry, so script-property enums like ENiagaraScriptLibraryVisibility and custom project enums resolve cleanly. FGraphNodeCreator contract fix - Op / FunctionCall / ParameterMapGet / ParameterMapSet / Reroute / Input node creation now calls Creator.Finalize() before the creator goes out of scope. Previous templated helper returned early and tripped the bPlaced assert (EdGraph.h:312). Bridge dispatcher prefix routing - Added trace_niagara, validate_niagara, apply_niagara, apply_and_save_niagara, clear_niagara, find_niagara, resolve_niagara prefixes so the new command names reach the Niagara handler rather than falling through to the generic "Unknown command" error. Python MCP wrappers - New @mcp.tool() entries in unrealmcp/tools/niagara.py for all new commands with full docstrings, keyword arg support, and optional param handling. Nothing to regenerate — drop-in. Go CLI parity - cli/cmd/niagara.go now ships 96 Niagara CommandSpecs (was 54). Every C++ command has a matching Go entry with Short/Long docs, ue-cli ... --flag examples, typed params, and Required flags where the handler requires them. ue-cli.exe rebuilt. Bonus: get_available_materials - Added filter + max_results params so material listings can be scoped without returning engine-wide dumps. Works on a real asset — verified end-to-end on NS_MuzzleFlash: read Spawn Count's GetDataInterfaceLength binding recursively, clear the nested Position Array link, re-link it through User.FlashPositions, build a 1:1 replica DI from scratch with a real array-Length DI function node, swap it onto SpawnPerFrame, set the nested int32001 local value to 250, and verified the full tree in a single binding query. No hand-editing in the editor.
1 parent 727615c commit f9bc023

18 files changed

Lines changed: 5850 additions & 178 deletions

Source/UnrealMCPBridge/Private/Commands/EpicUnrealMCPBlueprintCommands.cpp

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -789,6 +789,19 @@ TSharedPtr<FJsonObject> FEpicUnrealMCPBlueprintCommands::HandleGetAvailableMater
789789
bIncludeEngineMaterials = Params->GetBoolField(TEXT("include_engine_materials"));
790790
}
791791

792+
FString NameFilter;
793+
Params->TryGetStringField(TEXT("filter"), NameFilter);
794+
795+
int32 MaxResults = 100;
796+
if (Params->HasField(TEXT("max_results")))
797+
{
798+
double MaxResultsD = 100;
799+
if (Params->TryGetNumberField(TEXT("max_results"), MaxResultsD))
800+
{
801+
MaxResults = static_cast<int32>(MaxResultsD);
802+
}
803+
}
804+
792805
FAssetRegistryModule& AssetRegistryModule =
793806
FModuleManager::LoadModuleChecked<FAssetRegistryModule>(TEXT("AssetRegistry"));
794807
IAssetRegistry& AssetRegistry = AssetRegistryModule.Get();
@@ -866,6 +879,16 @@ TSharedPtr<FJsonObject> FEpicUnrealMCPBlueprintCommands::HandleGetAvailableMater
866879
TArray<TSharedPtr<FJsonValue>> MaterialArray;
867880
for (const FAssetData& AssetData : AssetDataArray)
868881
{
882+
if (MaxResults > 0 && MaterialArray.Num() >= MaxResults)
883+
{
884+
break;
885+
}
886+
887+
if (!NameFilter.IsEmpty() && !AssetData.AssetName.ToString().Contains(NameFilter, ESearchCase::IgnoreCase))
888+
{
889+
continue;
890+
}
891+
869892
TSharedPtr<FJsonObject> MaterialObj = MakeShared<FJsonObject>();
870893
MaterialObj->SetStringField(TEXT("name"), AssetData.AssetName.ToString());
871894
MaterialObj->SetStringField(TEXT("path"), AssetData.GetObjectPathString());

Source/UnrealMCPBridge/Private/Commands/EpicUnrealMCPNiagaraCommands.cpp

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,88 @@ TSharedPtr<FJsonObject> FEpicUnrealMCPNiagaraCommands::HandleCommand(
229229
return HandleRemoveNiagaraCustomHlslPin(Params);
230230
}
231231

232+
// ---- Graph introspection (scratch pad / script graphs) ----
233+
else if (CommandType == TEXT("get_niagara_graph_nodes"))
234+
{
235+
return HandleGetNiagaraGraphNodes(Params);
236+
}
237+
else if (CommandType == TEXT("get_niagara_node_info"))
238+
{
239+
return HandleGetNiagaraNodeInfo(Params);
240+
}
241+
else if (CommandType == TEXT("trace_niagara_connection"))
242+
{
243+
return HandleTraceNiagaraConnection(Params);
244+
}
245+
else if (CommandType == TEXT("validate_niagara_graph"))
246+
{
247+
return HandleValidateNiagaraGraph(Params);
248+
}
249+
else if (CommandType == TEXT("apply_niagara_scratch_pad"))
250+
{
251+
return HandleApplyNiagaraScratchPad(Params);
252+
}
253+
else if (CommandType == TEXT("apply_and_save_niagara_scratch_pad"))
254+
{
255+
return HandleApplyAndSaveNiagaraScratchPad(Params);
256+
}
257+
else if (CommandType == TEXT("get_niagara_script_properties"))
258+
{
259+
return HandleGetNiagaraScriptProperties(Params);
260+
}
261+
else if (CommandType == TEXT("set_niagara_script_properties"))
262+
{
263+
return HandleSetNiagaraScriptProperties(Params);
264+
}
265+
else if (CommandType == TEXT("list_niagara_script_parameters"))
266+
{
267+
return HandleListNiagaraScriptParameters(Params);
268+
}
269+
else if (CommandType == TEXT("add_niagara_script_parameter"))
270+
{
271+
return HandleAddNiagaraScriptParameter(Params);
272+
}
273+
else if (CommandType == TEXT("remove_niagara_script_parameter"))
274+
{
275+
return HandleRemoveNiagaraScriptParameter(Params);
276+
}
277+
else if (CommandType == TEXT("rename_niagara_script_parameter"))
278+
{
279+
return HandleRenameNiagaraScriptParameter(Params);
280+
}
281+
else if (CommandType == TEXT("add_niagara_graph_node"))
282+
{
283+
return HandleAddNiagaraGraphNode(Params);
284+
}
285+
else if (CommandType == TEXT("delete_niagara_graph_node"))
286+
{
287+
return HandleDeleteNiagaraGraphNode(Params);
288+
}
289+
else if (CommandType == TEXT("get_niagara_module_input_binding"))
290+
{
291+
return HandleGetNiagaraModuleInputBinding(Params);
292+
}
293+
else if (CommandType == TEXT("clear_niagara_module_input"))
294+
{
295+
return HandleClearNiagaraModuleInput(Params);
296+
}
297+
else if (CommandType == TEXT("list_niagara_input_source_menu"))
298+
{
299+
return HandleListNiagaraInputSourceMenu(Params);
300+
}
301+
else if (CommandType == TEXT("find_niagara_scratch_pad_usage"))
302+
{
303+
return HandleFindNiagaraScratchPadUsage(Params);
304+
}
305+
else if (CommandType == TEXT("resolve_niagara_built_in_dynamic_input"))
306+
{
307+
return HandleResolveNiagaraBuiltInDynamicInput(Params);
308+
}
309+
else if (CommandType == TEXT("list_niagara_data_interface_functions"))
310+
{
311+
return HandleListNiagaraDataInterfaceFunctions(Params);
312+
}
313+
232314
// ---- Node discovery & schema introspection ----
233315
else if (CommandType == TEXT("list_niagara_node_types"))
234316
{

Source/UnrealMCPBridge/Private/Commands/Niagara/NiagaraDiscovery.cpp

Lines changed: 152 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
#include "NiagaraGraph.h"
1111
#include "NiagaraDataInterface.h"
1212
#include "NiagaraTypes.h"
13+
#include "NiagaraTypeRegistry.h"
1314
#include "NiagaraCommon.h"
1415

1516
#include "AssetRegistry/AssetRegistryModule.h"
@@ -311,84 +312,182 @@ TSharedPtr<FJsonObject> FEpicUnrealMCPNiagaraCommands::HandleListNiagaraDataInte
311312
// HandleListNiagaraParameterTypes
312313
// ---------------------------------------------------------------------------
313314

315+
// Live query against FNiagaraTypeRegistry. Mirrors the "+" menu in the Niagara
316+
// editor's User Parameters panel — any type Niagara knows about (built-in,
317+
// Niagara-registered struct, enum, or plugin-registered Data Interface) is
318+
// returned. Filters: name substring, scope (user/system/emitter/particle/all),
319+
// kind (primitive/struct/enum/data_interface), max_results.
314320
TSharedPtr<FJsonObject> FEpicUnrealMCPNiagaraCommands::HandleListNiagaraParameterTypes(
315321
const TSharedPtr<FJsonObject>& Params)
316322
{
317-
FString Scope = TEXT("all");
323+
FString Scope = TEXT("user");
318324
Params->TryGetStringField(TEXT("scope"), Scope);
319325

320-
// Common Niagara parameter types available for user parameters
321-
struct FTypeEntry
326+
FString Kind = TEXT("all");
327+
Params->TryGetStringField(TEXT("kind"), Kind);
328+
329+
FString NameFilter;
330+
Params->TryGetStringField(TEXT("filter"), NameFilter);
331+
332+
int32 MaxResults = 500;
322333
{
323-
const TCHAR* Name;
324-
const TCHAR* Description;
325-
const TCHAR* Scope;
326-
};
334+
double MaxResultsD = 500.0;
335+
if (Params->TryGetNumberField(TEXT("max_results"), MaxResultsD))
336+
{
337+
MaxResults = static_cast<int32>(MaxResultsD);
338+
}
339+
}
327340

328-
static const FTypeEntry Types[] =
329-
{
330-
// Primitive types (available in all scopes)
331-
{ TEXT("float"), TEXT("32-bit floating point"), TEXT("all") },
332-
{ TEXT("int32"), TEXT("32-bit signed integer"), TEXT("all") },
333-
{ TEXT("bool"), TEXT("Boolean value"), TEXT("all") },
334-
335-
// Vector types
336-
{ TEXT("Vector2D"), TEXT("2D vector (X, Y)"), TEXT("all") },
337-
{ TEXT("Vector"), TEXT("3D vector (X, Y, Z)"), TEXT("all") },
338-
{ TEXT("Vector4"), TEXT("4D vector (X, Y, Z, W)"), TEXT("all") },
339-
340-
// Color
341-
{ TEXT("LinearColor"), TEXT("Linear color (R, G, B, A)"), TEXT("all") },
342-
343-
// Transform
344-
{ TEXT("Quat"), TEXT("Quaternion rotation"), TEXT("all") },
345-
{ TEXT("Matrix"), TEXT("4x4 matrix"), TEXT("particle") },
346-
{ TEXT("Position"), TEXT("World-space position (LWC)"), TEXT("all") },
347-
348-
// Niagara-specific
349-
{ TEXT("NiagaraID"), TEXT("Niagara particle ID"), TEXT("particle") },
350-
{ TEXT("NiagaraSpawnInfo"), TEXT("Spawn info struct"), TEXT("emitter") },
351-
{ TEXT("NiagaraParameterMap"), TEXT("Parameter map"), TEXT("system") },
352-
353-
// Enums
354-
{ TEXT("ENiagaraExecutionState"), TEXT("Emitter execution state enum"), TEXT("emitter") },
355-
{ TEXT("ENiagaraCoordinateSpace"), TEXT("Coordinate space enum"), TEXT("all") },
356-
{ TEXT("ENiagaraOrientationAxis"), TEXT("Orientation axis enum"), TEXT("all") },
357-
358-
// Data interfaces (user parameter scope)
359-
{ TEXT("Texture2D"), TEXT("2D texture reference"), TEXT("user") },
360-
{ TEXT("TextureCube"), TEXT("Cube texture reference"), TEXT("user") },
361-
{ TEXT("StaticMesh"), TEXT("Static mesh reference"), TEXT("user") },
362-
{ TEXT("SkeletalMesh"), TEXT("Skeletal mesh reference"), TEXT("user") },
341+
// Resolve scope → registry query. "all" returns every registered type.
342+
TArray<FNiagaraTypeDefinition> Types;
343+
const FString LowerScope = Scope.ToLower();
344+
if (LowerScope == TEXT("user"))
345+
{
346+
Types = FNiagaraTypeRegistry::GetRegisteredUserVariableTypes();
347+
}
348+
else if (LowerScope == TEXT("system"))
349+
{
350+
Types = FNiagaraTypeRegistry::GetRegisteredSystemVariableTypes();
351+
}
352+
else if (LowerScope == TEXT("emitter"))
353+
{
354+
Types = FNiagaraTypeRegistry::GetRegisteredEmitterVariableTypes();
355+
}
356+
else if (LowerScope == TEXT("particle"))
357+
{
358+
Types = FNiagaraTypeRegistry::GetRegisteredParticleVariableTypes();
359+
}
360+
else if (LowerScope == TEXT("parameter"))
361+
{
362+
Types = FNiagaraTypeRegistry::GetRegisteredParameterTypes();
363+
}
364+
else if (LowerScope == TEXT("payload"))
365+
{
366+
Types = FNiagaraTypeRegistry::GetRegisteredPayloadTypes();
367+
}
368+
else if (LowerScope == TEXT("numeric"))
369+
{
370+
Types = FNiagaraTypeRegistry::GetNumericTypes();
371+
}
372+
else if (LowerScope == TEXT("index"))
373+
{
374+
Types = FNiagaraTypeRegistry::GetIndexTypes();
375+
}
376+
else
377+
{
378+
Types = FNiagaraTypeRegistry::GetRegisteredTypes();
379+
}
380+
381+
const FString LowerKind = Kind.ToLower();
382+
383+
auto ClassifyKind = [](const FNiagaraTypeDefinition& T) -> FString
384+
{
385+
if (T.IsDataInterface()) return TEXT("data_interface");
386+
if (T.IsEnum()) return TEXT("enum");
387+
if (T.IsUObject()) return TEXT("object");
388+
if (T.GetScriptStruct()) return TEXT("struct");
389+
return TEXT("primitive");
363390
};
364391

365392
TArray<TSharedPtr<FJsonValue>> TypesArr;
366-
FString LowerScope = Scope.ToLower();
367-
368-
for (const FTypeEntry& Entry : Types)
393+
int32 TotalMatched = 0;
394+
for (const FNiagaraTypeDefinition& TypeDef : Types)
369395
{
370-
if (LowerScope != TEXT("all"))
396+
if (!TypeDef.IsValid())
397+
{
398+
continue;
399+
}
400+
401+
const FString EntryKind = ClassifyKind(TypeDef);
402+
if (LowerKind != TEXT("all") && !EntryKind.Equals(LowerKind, ESearchCase::IgnoreCase))
403+
{
404+
continue;
405+
}
406+
407+
const FString EntryName = TypeDef.GetName();
408+
if (!NameFilter.IsEmpty() && !EntryName.Contains(NameFilter, ESearchCase::IgnoreCase))
371409
{
372-
FString EntryScope = Entry.Scope;
373-
if (!EntryScope.Equals(TEXT("all"), ESearchCase::IgnoreCase) &&
374-
!EntryScope.Equals(Scope, ESearchCase::IgnoreCase))
410+
// For data interfaces, also allow matching without the "NiagaraDataInterface" prefix
411+
if (!(TypeDef.IsDataInterface() && EntryName.Replace(TEXT("NiagaraDataInterface"), TEXT("")).Contains(NameFilter, ESearchCase::IgnoreCase)))
375412
{
376413
continue;
377414
}
378415
}
379416

417+
TotalMatched++;
418+
if (TypesArr.Num() >= MaxResults)
419+
{
420+
continue;
421+
}
422+
380423
auto Obj = MakeShared<FJsonObject>();
381-
Obj->SetStringField(TEXT("name"), Entry.Name);
382-
Obj->SetStringField(TEXT("description"), Entry.Description);
383-
Obj->SetStringField(TEXT("scope"), Entry.Scope);
424+
Obj->SetStringField(TEXT("name"), EntryName);
425+
Obj->SetStringField(TEXT("kind"), EntryKind);
426+
Obj->SetNumberField(TEXT("size_bytes"), TypeDef.GetSize());
427+
428+
if (TypeDef.IsDataInterface())
429+
{
430+
if (UClass* Cls = TypeDef.GetClass())
431+
{
432+
Obj->SetStringField(TEXT("class_path"), Cls->GetPathName());
433+
// Convenience short name — strips engine-internal prefix so users
434+
// can reference "ArrayFloat" instead of "NiagaraDataInterfaceArrayFloat"
435+
FString Short = Cls->GetName();
436+
if (Short.StartsWith(TEXT("NiagaraDataInterface")))
437+
{
438+
Short.RightChopInline(FString(TEXT("NiagaraDataInterface")).Len());
439+
Obj->SetStringField(TEXT("short_name"), Short);
440+
}
441+
// Category from metadata (used by editor "+"-menu grouping)
442+
#if WITH_EDITOR
443+
const FString Category = Cls->GetMetaData(TEXT("Category"));
444+
if (!Category.IsEmpty())
445+
{
446+
Obj->SetStringField(TEXT("category"), Category);
447+
}
448+
const FString Tooltip = Cls->GetMetaData(TEXT("ToolTip"));
449+
if (!Tooltip.IsEmpty())
450+
{
451+
Obj->SetStringField(TEXT("description"), Tooltip);
452+
}
453+
#endif
454+
}
455+
}
456+
else if (TypeDef.IsEnum())
457+
{
458+
if (UEnum* Enum = TypeDef.GetEnum())
459+
{
460+
Obj->SetStringField(TEXT("enum_path"), Enum->GetPathName());
461+
Obj->SetNumberField(TEXT("num_entries"), Enum->NumEnums());
462+
}
463+
}
464+
else if (UScriptStruct* Struct = TypeDef.GetScriptStruct())
465+
{
466+
Obj->SetStringField(TEXT("struct_path"), Struct->GetPathName());
467+
#if WITH_EDITOR
468+
const FString Tooltip = Struct->GetMetaData(TEXT("ToolTip"));
469+
if (!Tooltip.IsEmpty())
470+
{
471+
Obj->SetStringField(TEXT("description"), Tooltip);
472+
}
473+
#endif
474+
}
475+
384476
TypesArr.Add(MakeShared<FJsonValueObject>(Obj));
385477
}
386478

387479
auto Result = MakeShared<FJsonObject>();
388480
Result->SetBoolField(TEXT("success"), true);
389-
Result->SetStringField(TEXT("scope_filter"), Scope);
481+
Result->SetStringField(TEXT("scope"), Scope);
482+
Result->SetStringField(TEXT("kind_filter"), Kind);
483+
if (!NameFilter.IsEmpty())
484+
{
485+
Result->SetStringField(TEXT("name_filter"), NameFilter);
486+
}
390487
Result->SetArrayField(TEXT("types"), TypesArr);
391488
Result->SetNumberField(TEXT("count"), TypesArr.Num());
489+
Result->SetNumberField(TEXT("total_matched"), TotalMatched);
490+
Result->SetNumberField(TEXT("max_results"), MaxResults);
392491
return Result;
393492
}
394493

0 commit comments

Comments
 (0)