ue-mcp 1.1.19 → 1.1.21

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 (45) hide show
  1. package/README.md +1 -1
  2. package/dist/bridge.d.ts +18 -5
  3. package/dist/bridge.js +57 -14
  4. package/dist/bridge.js.map +1 -1
  5. package/dist/errors.d.ts +4 -1
  6. package/dist/errors.js +3 -0
  7. package/dist/errors.js.map +1 -1
  8. package/dist/global-config.d.ts +2 -2
  9. package/dist/global-config.js +2 -2
  10. package/dist/index.js +13 -3
  11. package/dist/index.js.map +1 -1
  12. package/dist/init.js +7 -0
  13. package/dist/init.js.map +1 -1
  14. package/dist/locking.d.ts +29 -0
  15. package/dist/locking.js +136 -0
  16. package/dist/locking.js.map +1 -0
  17. package/dist/port.d.ts +16 -0
  18. package/dist/port.js +42 -0
  19. package/dist/port.js.map +1 -0
  20. package/dist/project.d.ts +11 -0
  21. package/dist/project.js +1 -1
  22. package/dist/project.js.map +1 -1
  23. package/dist/schemas.d.ts +51 -0
  24. package/dist/schemas.js +19 -0
  25. package/dist/schemas.js.map +1 -1
  26. package/dist/tool-counts.json +6 -6
  27. package/dist/tools/asset.js +8 -0
  28. package/dist/tools/asset.js.map +1 -1
  29. package/dist/tools/blueprint.js +1 -0
  30. package/dist/tools/blueprint.js.map +1 -1
  31. package/dist/tools/feedback.d.ts +1 -1
  32. package/dist/tools/feedback.js +17 -14
  33. package/dist/tools/feedback.js.map +1 -1
  34. package/dist/ue-mcp.default.yml +25 -1
  35. package/package.json +2 -2
  36. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/BridgeServer.cpp +65 -0
  37. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/BridgeServer.h +11 -0
  38. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/DiffHandlers.cpp +328 -0
  39. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/DiffHandlers.h +36 -0
  40. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/LockHandlers.cpp +107 -0
  41. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/LockHandlers.h +25 -0
  42. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/NiagaraHandlers.cpp +66 -2
  43. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/LockRegistry.cpp +121 -0
  44. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/LockRegistry.h +67 -0
  45. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/UE_MCP_Bridge.cpp +8 -3
@@ -0,0 +1,328 @@
1
+ #include "Handlers/DiffHandlers.h"
2
+ #include "HandlerRegistry.h"
3
+ #include "HandlerUtils.h"
4
+ #include "Engine/Blueprint.h"
5
+ #include "EdGraph/EdGraph.h"
6
+ #include "EdGraph/EdGraphNode.h"
7
+ #include "EdGraph/EdGraphPin.h"
8
+ #include "Engine/SimpleConstructionScript.h"
9
+ #include "Engine/SCS_Node.h"
10
+
11
+ // ── Structural extraction helpers ────────────────────────────────────────────
12
+ namespace
13
+ {
14
+ /** Compact, stable string for a pin type, e.g. "int", "object(Actor)",
15
+ * "struct(Vector)[]". Used to detect variable type changes. */
16
+ FString PinTypeToShortString(const FEdGraphPinType& T)
17
+ {
18
+ FString S = T.PinCategory.ToString();
19
+ if (const UObject* Sub = T.PinSubCategoryObject.Get())
20
+ {
21
+ S += TEXT("(") + Sub->GetName() + TEXT(")");
22
+ }
23
+ else if (!T.PinSubCategory.IsNone())
24
+ {
25
+ S += TEXT("(") + T.PinSubCategory.ToString() + TEXT(")");
26
+ }
27
+ switch (T.ContainerType)
28
+ {
29
+ case EPinContainerType::Array: S += TEXT("[]"); break;
30
+ case EPinContainerType::Set: S += TEXT("{}"); break;
31
+ case EPinContainerType::Map: S += TEXT("{:}"); break;
32
+ default: break;
33
+ }
34
+ return S;
35
+ }
36
+
37
+ /** All named graphs of a Blueprint keyed by graph name (event graphs +
38
+ * function graphs + macro graphs). */
39
+ TMap<FString, UEdGraph*> CollectGraphs(UBlueprint* BP)
40
+ {
41
+ TMap<FString, UEdGraph*> Out;
42
+ auto Add = [&Out](const TArray<UEdGraph*>& Graphs)
43
+ {
44
+ for (UEdGraph* G : Graphs)
45
+ {
46
+ if (G) Out.Add(G->GetName(), G);
47
+ }
48
+ };
49
+ Add(BP->UbergraphPages);
50
+ Add(BP->FunctionGraphs);
51
+ Add(BP->MacroGraphs);
52
+ return Out;
53
+ }
54
+
55
+ TSharedPtr<FJsonObject> NodeSummary(UEdGraphNode* Node)
56
+ {
57
+ TSharedPtr<FJsonObject> Obj = MakeShared<FJsonObject>();
58
+ Obj->SetStringField(TEXT("id"), Node->NodeGuid.ToString());
59
+ Obj->SetStringField(TEXT("class"), Node->GetClass()->GetName());
60
+ Obj->SetStringField(TEXT("title"), Node->GetNodeTitle(ENodeTitleType::ListView).ToString());
61
+ return Obj;
62
+ }
63
+
64
+ /** Canonical strings for every connection in a graph, emitted once per link
65
+ * (from the output side). Format: "SrcGuid.Pin -> DstGuid.Pin". */
66
+ TSet<FString> CollectConnections(UEdGraph* Graph)
67
+ {
68
+ TSet<FString> Out;
69
+ for (UEdGraphNode* Node : Graph->Nodes)
70
+ {
71
+ if (!Node) continue;
72
+ for (UEdGraphPin* Pin : Node->Pins)
73
+ {
74
+ if (!Pin || Pin->Direction != EGPD_Output) continue;
75
+ for (UEdGraphPin* Linked : Pin->LinkedTo)
76
+ {
77
+ if (!Linked || !Linked->GetOwningNodeUnchecked()) continue;
78
+ Out.Add(FString::Printf(TEXT("%s.%s -> %s.%s"),
79
+ *Node->NodeGuid.ToString(), *Pin->PinName.ToString(),
80
+ *Linked->GetOwningNode()->NodeGuid.ToString(), *Linked->PinName.ToString()));
81
+ }
82
+ }
83
+ }
84
+ return Out;
85
+ }
86
+
87
+ TArray<TSharedPtr<FJsonValue>> StringsToJson(const TArray<FString>& In)
88
+ {
89
+ TArray<TSharedPtr<FJsonValue>> Out;
90
+ Out.Reserve(In.Num());
91
+ for (const FString& S : In) Out.Add(MakeShared<FJsonValueString>(S));
92
+ return Out;
93
+ }
94
+ }
95
+
96
+ void FDiffHandlers::RegisterHandlers(FMCPHandlerRegistry& Registry)
97
+ {
98
+ Registry.RegisterHandler(TEXT("diff_blueprint"), &FDiffHandlers::DiffBlueprint);
99
+ Registry.RegisterHandler(TEXT("diff_asset"), &FDiffHandlers::DiffAsset);
100
+ }
101
+
102
+ TSharedPtr<FJsonValue> FDiffHandlers::DiffBlueprint(const TSharedPtr<FJsonObject>& Params)
103
+ {
104
+ FString PathA;
105
+ if (TSharedPtr<FJsonValue> Err = RequireStringAlt(Params, TEXT("assetPath"), TEXT("path"), PathA)) return Err;
106
+ FString PathB;
107
+ if (TSharedPtr<FJsonValue> Err = RequireString(Params, TEXT("otherPath"), PathB)) return Err;
108
+
109
+ // Revision-based diffing (loading a depot revision into a transient package)
110
+ // is a documented follow-up; for now both sides are loadable asset paths.
111
+ if (Params->HasField(TEXT("fromRevision")) || Params->HasField(TEXT("toRevision")))
112
+ {
113
+ return MCPError(TEXT("Revision-based diffing (fromRevision/toRevision via source control) is not wired yet - pass 'otherPath' to diff two loaded Blueprint assets. Revision loading is a staged follow-up."));
114
+ }
115
+
116
+ UBlueprint* A = LoadAssetByPath<UBlueprint>(PathA);
117
+ if (!A) return MCPError(FString::Printf(TEXT("Blueprint not found: %s"), *PathA));
118
+ UBlueprint* B = LoadAssetByPath<UBlueprint>(PathB);
119
+ if (!B) return MCPError(FString::Printf(TEXT("Blueprint not found: %s"), *PathB));
120
+
121
+ TSharedPtr<FJsonObject> Result = MCPSuccess();
122
+ Result->SetStringField(TEXT("assetType"), TEXT("Blueprint"));
123
+ Result->SetStringField(TEXT("from"), PathA);
124
+ Result->SetStringField(TEXT("to"), PathB);
125
+
126
+ int32 ChangeCount = 0;
127
+
128
+ // ── Parent class ────────────────────────────────────────────────────────
129
+ const FString ParentA = A->ParentClass ? A->ParentClass->GetPathName() : TEXT("");
130
+ const FString ParentB = B->ParentClass ? B->ParentClass->GetPathName() : TEXT("");
131
+ if (ParentA != ParentB)
132
+ {
133
+ TSharedPtr<FJsonObject> PC = MakeShared<FJsonObject>();
134
+ PC->SetStringField(TEXT("from"), ParentA);
135
+ PC->SetStringField(TEXT("to"), ParentB);
136
+ Result->SetObjectField(TEXT("parentClassChanged"), PC);
137
+ ++ChangeCount;
138
+ }
139
+
140
+ // ── Variables ───────────────────────────────────────────────────────────
141
+ auto VarMap = [](UBlueprint* BP)
142
+ {
143
+ TMap<FString, TPair<FString, FString>> M; // name -> (type, default)
144
+ for (const FBPVariableDescription& V : BP->NewVariables)
145
+ {
146
+ M.Add(V.VarName.ToString(), TPair<FString, FString>(PinTypeToShortString(V.VarType), V.DefaultValue));
147
+ }
148
+ return M;
149
+ };
150
+ const TMap<FString, TPair<FString, FString>> VA = VarMap(A);
151
+ const TMap<FString, TPair<FString, FString>> VB = VarMap(B);
152
+ {
153
+ TArray<FString> Added, Removed;
154
+ TArray<TSharedPtr<FJsonValue>> Changed;
155
+ for (const auto& Pair : VB) if (!VA.Contains(Pair.Key)) Added.Add(Pair.Key);
156
+ for (const auto& Pair : VA)
157
+ {
158
+ if (const TPair<FString, FString>* Bv = VB.Find(Pair.Key))
159
+ {
160
+ if (Bv->Key != Pair.Value.Key || Bv->Value != Pair.Value.Value)
161
+ {
162
+ TSharedPtr<FJsonObject> C = MakeShared<FJsonObject>();
163
+ C->SetStringField(TEXT("name"), Pair.Key);
164
+ C->SetStringField(TEXT("fromType"), Pair.Value.Key);
165
+ C->SetStringField(TEXT("toType"), Bv->Key);
166
+ C->SetStringField(TEXT("fromDefault"), Pair.Value.Value);
167
+ C->SetStringField(TEXT("toDefault"), Bv->Value);
168
+ Changed.Add(MakeShared<FJsonValueObject>(C));
169
+ }
170
+ }
171
+ else
172
+ {
173
+ Removed.Add(Pair.Key);
174
+ }
175
+ }
176
+ if (Added.Num() || Removed.Num() || Changed.Num())
177
+ {
178
+ TSharedPtr<FJsonObject> V = MakeShared<FJsonObject>();
179
+ V->SetArrayField(TEXT("added"), StringsToJson(Added));
180
+ V->SetArrayField(TEXT("removed"), StringsToJson(Removed));
181
+ V->SetArrayField(TEXT("changed"), Changed);
182
+ Result->SetObjectField(TEXT("variables"), V);
183
+ ChangeCount += Added.Num() + Removed.Num() + Changed.Num();
184
+ }
185
+ }
186
+
187
+ // ── Components (SimpleConstructionScript) ───────────────────────────────
188
+ auto CompMap = [](UBlueprint* BP)
189
+ {
190
+ TMap<FString, FString> M; // name -> class
191
+ if (BP->SimpleConstructionScript)
192
+ {
193
+ for (USCS_Node* Node : BP->SimpleConstructionScript->GetAllNodes())
194
+ {
195
+ if (!Node) continue;
196
+ const FString Name = Node->GetVariableName().ToString();
197
+ const FString Cls = Node->ComponentClass ? Node->ComponentClass->GetName() : TEXT("");
198
+ M.Add(Name, Cls);
199
+ }
200
+ }
201
+ return M;
202
+ };
203
+ const TMap<FString, FString> CA = CompMap(A);
204
+ const TMap<FString, FString> CB = CompMap(B);
205
+ {
206
+ TArray<TSharedPtr<FJsonValue>> Added, Removed, Changed;
207
+ for (const auto& Pair : CB) if (!CA.Contains(Pair.Key))
208
+ {
209
+ TSharedPtr<FJsonObject> O = MakeShared<FJsonObject>();
210
+ O->SetStringField(TEXT("name"), Pair.Key);
211
+ O->SetStringField(TEXT("class"), Pair.Value);
212
+ Added.Add(MakeShared<FJsonValueObject>(O));
213
+ }
214
+ for (const auto& Pair : CA)
215
+ {
216
+ if (const FString* Bc = CB.Find(Pair.Key))
217
+ {
218
+ if (*Bc != Pair.Value)
219
+ {
220
+ TSharedPtr<FJsonObject> O = MakeShared<FJsonObject>();
221
+ O->SetStringField(TEXT("name"), Pair.Key);
222
+ O->SetStringField(TEXT("fromClass"), Pair.Value);
223
+ O->SetStringField(TEXT("toClass"), *Bc);
224
+ Changed.Add(MakeShared<FJsonValueObject>(O));
225
+ }
226
+ }
227
+ else
228
+ {
229
+ TSharedPtr<FJsonObject> O = MakeShared<FJsonObject>();
230
+ O->SetStringField(TEXT("name"), Pair.Key);
231
+ O->SetStringField(TEXT("class"), Pair.Value);
232
+ Removed.Add(MakeShared<FJsonValueObject>(O));
233
+ }
234
+ }
235
+ if (Added.Num() || Removed.Num() || Changed.Num())
236
+ {
237
+ TSharedPtr<FJsonObject> C = MakeShared<FJsonObject>();
238
+ C->SetArrayField(TEXT("added"), Added);
239
+ C->SetArrayField(TEXT("removed"), Removed);
240
+ C->SetArrayField(TEXT("changed"), Changed);
241
+ Result->SetObjectField(TEXT("components"), C);
242
+ ChangeCount += Added.Num() + Removed.Num() + Changed.Num();
243
+ }
244
+ }
245
+
246
+ // ── Graphs: node + connection deltas keyed on stable NodeGuid ───────────
247
+ const TMap<FString, UEdGraph*> GA = CollectGraphs(A);
248
+ const TMap<FString, UEdGraph*> GB = CollectGraphs(B);
249
+ {
250
+ // Functions/macros/event-graphs present on only one side.
251
+ TArray<FString> GraphsAdded, GraphsRemoved;
252
+ for (const auto& Pair : GB) if (!GA.Contains(Pair.Key)) GraphsAdded.Add(Pair.Key);
253
+ for (const auto& Pair : GA) if (!GB.Contains(Pair.Key)) GraphsRemoved.Add(Pair.Key);
254
+ if (GraphsAdded.Num() || GraphsRemoved.Num())
255
+ {
256
+ TSharedPtr<FJsonObject> G = MakeShared<FJsonObject>();
257
+ G->SetArrayField(TEXT("added"), StringsToJson(GraphsAdded));
258
+ G->SetArrayField(TEXT("removed"), StringsToJson(GraphsRemoved));
259
+ Result->SetObjectField(TEXT("graphsAddedRemoved"), G);
260
+ ChangeCount += GraphsAdded.Num() + GraphsRemoved.Num();
261
+ }
262
+
263
+ TArray<TSharedPtr<FJsonValue>> GraphDeltas;
264
+ for (const auto& Pair : GA)
265
+ {
266
+ UEdGraph* GraphA = Pair.Value;
267
+ UEdGraph* const* GraphBPtr = GB.Find(Pair.Key);
268
+ if (!GraphBPtr) continue; // whole-graph add/remove handled above
269
+ UEdGraph* GraphB = *GraphBPtr;
270
+
271
+ TMap<FString, UEdGraphNode*> NodesA, NodesB;
272
+ for (UEdGraphNode* N : GraphA->Nodes) if (N) NodesA.Add(N->NodeGuid.ToString(), N);
273
+ for (UEdGraphNode* N : GraphB->Nodes) if (N) NodesB.Add(N->NodeGuid.ToString(), N);
274
+
275
+ TArray<TSharedPtr<FJsonValue>> NodesAdded, NodesRemoved;
276
+ for (const auto& NB : NodesB) if (!NodesA.Contains(NB.Key)) NodesAdded.Add(MakeShared<FJsonValueObject>(NodeSummary(NB.Value)));
277
+ for (const auto& NA : NodesA) if (!NodesB.Contains(NA.Key)) NodesRemoved.Add(MakeShared<FJsonValueObject>(NodeSummary(NA.Value)));
278
+
279
+ const TSet<FString> ConnA = CollectConnections(GraphA);
280
+ const TSet<FString> ConnB = CollectConnections(GraphB);
281
+ TArray<FString> ConnAdded, ConnRemoved;
282
+ for (const FString& C : ConnB) if (!ConnA.Contains(C)) ConnAdded.Add(C);
283
+ for (const FString& C : ConnA) if (!ConnB.Contains(C)) ConnRemoved.Add(C);
284
+
285
+ if (NodesAdded.Num() || NodesRemoved.Num() || ConnAdded.Num() || ConnRemoved.Num())
286
+ {
287
+ TSharedPtr<FJsonObject> GD = MakeShared<FJsonObject>();
288
+ GD->SetStringField(TEXT("graph"), Pair.Key);
289
+ GD->SetArrayField(TEXT("nodesAdded"), NodesAdded);
290
+ GD->SetArrayField(TEXT("nodesRemoved"), NodesRemoved);
291
+ GD->SetArrayField(TEXT("connectionsAdded"), StringsToJson(ConnAdded));
292
+ GD->SetArrayField(TEXT("connectionsRemoved"), StringsToJson(ConnRemoved));
293
+ GraphDeltas.Add(MakeShared<FJsonValueObject>(GD));
294
+ ChangeCount += NodesAdded.Num() + NodesRemoved.Num() + ConnAdded.Num() + ConnRemoved.Num();
295
+ }
296
+ }
297
+ if (GraphDeltas.Num())
298
+ {
299
+ Result->SetArrayField(TEXT("graphs"), GraphDeltas);
300
+ }
301
+ }
302
+
303
+ Result->SetNumberField(TEXT("changeCount"), ChangeCount);
304
+ Result->SetBoolField(TEXT("identical"), ChangeCount == 0);
305
+ Result->SetStringField(TEXT("summary"), ChangeCount == 0
306
+ ? FString::Printf(TEXT("%s and %s are structurally identical"), *PathA, *PathB)
307
+ : FString::Printf(TEXT("%d structural change(s) from %s to %s"), ChangeCount, *PathA, *PathB));
308
+
309
+ return MCPResult(Result);
310
+ }
311
+
312
+ TSharedPtr<FJsonValue> FDiffHandlers::DiffAsset(const TSharedPtr<FJsonObject>& Params)
313
+ {
314
+ FString PathA;
315
+ if (TSharedPtr<FJsonValue> Err = RequireStringAlt(Params, TEXT("assetPath"), TEXT("path"), PathA)) return Err;
316
+
317
+ if (UObject* Asset = LoadAssetByPath<UObject>(PathA))
318
+ {
319
+ if (Asset->IsA<UBlueprint>())
320
+ {
321
+ return DiffBlueprint(Params);
322
+ }
323
+ return MCPError(FString::Printf(
324
+ TEXT("Diffing '%s' assets is not supported yet. Blueprint diffing is available now; StateTree and other graph assets are staged follow-ups."),
325
+ *Asset->GetClass()->GetName()));
326
+ }
327
+ return MCPError(FString::Printf(TEXT("Asset not found: %s"), *PathA));
328
+ }
@@ -0,0 +1,36 @@
1
+ #pragma once
2
+
3
+ #include "CoreMinimal.h"
4
+ #include "Dom/JsonValue.h"
5
+ #include "Dom/JsonObject.h"
6
+
7
+ /**
8
+ * Semantic revision diffing for graph-based binary assets. Blueprint and
9
+ * StateTree assets are opaque to git, so an agent's edits are unreviewable:
10
+ * there is no way to answer "what did this change between two versions".
11
+ *
12
+ * Phase 1 (here): Blueprint structural diff between two loadable asset paths —
13
+ * parent class, variables, functions/macros, components, and per-graph node and
14
+ * connection deltas, keyed on the stable node GUID so a moved or edited node is
15
+ * matched rather than reported as remove+add. Output is a structured JSON delta
16
+ * plus a human-readable summary.
17
+ *
18
+ * Follow-ups (documented, not yet wired): loading an arbitrary source-control
19
+ * revision into a transient package so the "to" side can be a depot revision
20
+ * rather than a second on-disk asset (needs a live SCC spike), then StateTree
21
+ * assets on the same diff spine.
22
+ */
23
+ class FDiffHandlers
24
+ {
25
+ public:
26
+ static void RegisterHandlers(class FMCPHandlerRegistry& Registry);
27
+
28
+ private:
29
+ // Diff two Blueprints loaded from asset paths. Params: assetPath (base/A),
30
+ // otherPath (compare/B). Reports changes as A -> B.
31
+ static TSharedPtr<FJsonValue> DiffBlueprint(const TSharedPtr<FJsonObject>& Params);
32
+
33
+ // Type-routing entry point. Blueprints delegate to DiffBlueprint; other
34
+ // asset classes return a clear "not yet supported" until their phase lands.
35
+ static TSharedPtr<FJsonValue> DiffAsset(const TSharedPtr<FJsonObject>& Params);
36
+ };
@@ -0,0 +1,107 @@
1
+ #include "Handlers/LockHandlers.h"
2
+ #include "HandlerRegistry.h"
3
+ #include "HandlerUtils.h"
4
+ #include "LockRegistry.h"
5
+ #include "HAL/PlatformTime.h"
6
+
7
+ namespace
8
+ {
9
+ /** Serialize a lock record to the wire shape shared by list/acquire/release. */
10
+ TSharedPtr<FJsonObject> LockToJson(const FMCPAssetLock& Lock)
11
+ {
12
+ const double Remaining = FMath::Max(0.0, Lock.ExpiresMono - FPlatformTime::Seconds());
13
+ TSharedPtr<FJsonObject> Obj = MakeShared<FJsonObject>();
14
+ Obj->SetStringField(TEXT("path"), Lock.AssetPath);
15
+ Obj->SetStringField(TEXT("sessionId"), Lock.SessionId);
16
+ Obj->SetStringField(TEXT("acquiredAt"), Lock.AcquiredAtUtc.ToIso8601());
17
+ Obj->SetNumberField(TEXT("ttlSeconds"), Lock.TtlSeconds);
18
+ Obj->SetNumberField(TEXT("ttlSecondsRemaining"), Remaining);
19
+ return Obj;
20
+ }
21
+ }
22
+
23
+ void FLockHandlers::RegisterHandlers(FMCPHandlerRegistry& Registry)
24
+ {
25
+ Registry.RegisterHandler(TEXT("acquire_lock"), &FLockHandlers::AcquireLock);
26
+ Registry.RegisterHandler(TEXT("release_lock"), &FLockHandlers::ReleaseLock);
27
+ Registry.RegisterHandler(TEXT("release_session_locks"), &FLockHandlers::ReleaseSessionLocks);
28
+ Registry.RegisterHandler(TEXT("list_locks"), &FLockHandlers::ListLocks);
29
+ }
30
+
31
+ TSharedPtr<FJsonValue> FLockHandlers::AcquireLock(const TSharedPtr<FJsonObject>& Params)
32
+ {
33
+ FString Path;
34
+ if (TSharedPtr<FJsonValue> Err = RequireStringAlt(Params, TEXT("path"), TEXT("assetPath"), Path)) return Err;
35
+ FString SessionId;
36
+ if (TSharedPtr<FJsonValue> Err = RequireString(Params, TEXT("sessionId"), SessionId)) return Err;
37
+
38
+ double Ttl = OptionalNumber(Params, TEXT("ttlSeconds"), 300.0);
39
+ if (Ttl <= 0.0) Ttl = 300.0;
40
+
41
+ FMCPAssetLock Holder;
42
+ const bool bAcquired = FMCPLockRegistry::Get().Acquire(Path, SessionId, Ttl, Holder);
43
+
44
+ TSharedPtr<FJsonObject> Res = MCPSuccess();
45
+ Res->SetBoolField(TEXT("acquired"), bAcquired);
46
+ Res->SetStringField(TEXT("path"), Path);
47
+ Res->SetStringField(TEXT("sessionId"), SessionId);
48
+ if (bAcquired)
49
+ {
50
+ Res->SetNumberField(TEXT("ttlSeconds"), Ttl);
51
+ Res->SetNumberField(TEXT("ttlSecondsRemaining"), FMath::Max(0.0, Holder.ExpiresMono - FPlatformTime::Seconds()));
52
+ }
53
+ else
54
+ {
55
+ // Busy: describe the current holder so the caller (or the Node
56
+ // middleware) can surface a retryable error.
57
+ Res->SetObjectField(TEXT("holder"), LockToJson(Holder));
58
+ }
59
+ return MCPResult(Res);
60
+ }
61
+
62
+ TSharedPtr<FJsonValue> FLockHandlers::ReleaseLock(const TSharedPtr<FJsonObject>& Params)
63
+ {
64
+ FString Path;
65
+ if (TSharedPtr<FJsonValue> Err = RequireStringAlt(Params, TEXT("path"), TEXT("assetPath"), Path)) return Err;
66
+ FString SessionId;
67
+ if (TSharedPtr<FJsonValue> Err = RequireString(Params, TEXT("sessionId"), SessionId)) return Err;
68
+ const bool bForce = OptionalBool(Params, TEXT("force"), false);
69
+
70
+ FMCPAssetLock Released;
71
+ const bool bReleased = FMCPLockRegistry::Get().Release(Path, SessionId, bForce, Released);
72
+
73
+ TSharedPtr<FJsonObject> Res = MCPSuccess();
74
+ Res->SetBoolField(TEXT("released"), bReleased);
75
+ Res->SetStringField(TEXT("path"), Path);
76
+ return MCPResult(Res);
77
+ }
78
+
79
+ TSharedPtr<FJsonValue> FLockHandlers::ReleaseSessionLocks(const TSharedPtr<FJsonObject>& Params)
80
+ {
81
+ FString SessionId;
82
+ if (TSharedPtr<FJsonValue> Err = RequireString(Params, TEXT("sessionId"), SessionId)) return Err;
83
+
84
+ const int32 Count = FMCPLockRegistry::Get().ReleaseSession(SessionId);
85
+
86
+ TSharedPtr<FJsonObject> Res = MCPSuccess();
87
+ Res->SetStringField(TEXT("sessionId"), SessionId);
88
+ Res->SetNumberField(TEXT("released"), Count);
89
+ return MCPResult(Res);
90
+ }
91
+
92
+ TSharedPtr<FJsonValue> FLockHandlers::ListLocks(const TSharedPtr<FJsonObject>& Params)
93
+ {
94
+ const TArray<FMCPAssetLock> Locks = FMCPLockRegistry::Get().List();
95
+
96
+ TArray<TSharedPtr<FJsonValue>> Arr;
97
+ Arr.Reserve(Locks.Num());
98
+ for (const FMCPAssetLock& Lock : Locks)
99
+ {
100
+ Arr.Add(MakeShared<FJsonValueObject>(LockToJson(Lock)));
101
+ }
102
+
103
+ TSharedPtr<FJsonObject> Res = MCPSuccess();
104
+ Res->SetArrayField(TEXT("locks"), Arr);
105
+ Res->SetNumberField(TEXT("count"), Locks.Num());
106
+ return MCPResult(Res);
107
+ }
@@ -0,0 +1,25 @@
1
+ #pragma once
2
+
3
+ #include "CoreMinimal.h"
4
+ #include "Dom/JsonValue.h"
5
+ #include "Dom/JsonObject.h"
6
+
7
+ /**
8
+ * Per-asset exclusive locking so concurrent agents (or an agent plus a human)
9
+ * don't issue conflicting mutations against the same asset. Backed by
10
+ * FMCPLockRegistry, which lives in the bridge — the single shared editor every
11
+ * agent connects to. Exposed as explicit acquire/release/list actions; the
12
+ * Node dispatch layer also wraps mutating calls with these when locking is
13
+ * enabled (see src/locking.ts).
14
+ */
15
+ class FLockHandlers
16
+ {
17
+ public:
18
+ static void RegisterHandlers(class FMCPHandlerRegistry& Registry);
19
+
20
+ private:
21
+ static TSharedPtr<FJsonValue> AcquireLock(const TSharedPtr<FJsonObject>& Params);
22
+ static TSharedPtr<FJsonValue> ReleaseLock(const TSharedPtr<FJsonObject>& Params);
23
+ static TSharedPtr<FJsonValue> ReleaseSessionLocks(const TSharedPtr<FJsonObject>& Params);
24
+ static TSharedPtr<FJsonValue> ListLocks(const TSharedPtr<FJsonObject>& Params);
25
+ };
@@ -27,6 +27,7 @@
27
27
  #include "NiagaraNodeCustomHlsl.h"
28
28
  #include "NiagaraEmitterHandle.h"
29
29
  #include "NiagaraEditorUtilities.h"
30
+ #include "NiagaraEmitterFactoryNew.h"
30
31
  #include "Editor.h"
31
32
  #include "Engine/World.h"
32
33
  #include "EngineUtils.h"
@@ -251,7 +252,18 @@ TSharedPtr<FJsonValue> FNiagaraHandlers::CreateNiagaraEmitter(const TSharedPtr<F
251
252
  return MCPError(TEXT("NiagaraEmitter class not found - factory not available"));
252
253
  }
253
254
 
254
- auto Created = MCPCreateAssetIdempotent<UObject>(Name, PackagePath, OnConflict, TEXT("NiagaraEmitter"), EmitterClass, nullptr);
255
+ // Build the emitter through UNiagaraEmitterFactoryNew, not a bare NewObject.
256
+ // The factory's empty-emitter path initializes a UNiagaraScriptSource +
257
+ // graph (GraphSource) plus default modules and a sprite renderer - the same
258
+ // asset the "Niagara Emitter" content-browser action produces. A bare
259
+ // NewObject'd emitter has GraphSource==nullptr, "succeeds" here, then
260
+ // crashes the editor the moment it is added to a system. IAssetTools::
261
+ // CreateAsset runs FactoryCreateNew headlessly (no ConfigureProperties UI),
262
+ // and the factory defaults (EmitterToCopy=null) select the empty path.
263
+ UNiagaraEmitterFactoryNew* Factory = NewObject<UNiagaraEmitterFactoryNew>();
264
+ FGCRootScope FactoryRoot(Factory);
265
+
266
+ auto Created = MCPCreateAssetIdempotent<UObject>(Name, PackagePath, OnConflict, TEXT("NiagaraEmitter"), EmitterClass, Factory);
255
267
  if (Created.EarlyReturn) return Created.EarlyReturn;
256
268
 
257
269
  UEditorAssetLibrary::SaveAsset(Created.Asset->GetPathName());
@@ -532,6 +544,22 @@ TSharedPtr<FJsonValue> FNiagaraHandlers::SetNiagaraParameter(const TSharedPtr<FJ
532
544
  return MCPResult(Result);
533
545
  }
534
546
 
547
+ // An emitter is only safely addable to a system when its target version has a
548
+ // GraphSource. A bare NewObject'd UNiagaraEmitter (how create_niagara_emitter
549
+ // used to build them) or any asset left in a broken state has
550
+ // GraphSource==nullptr; passing one to FNiagaraEditorUtilities::AddEmitterToSystem
551
+ // makes Niagara's RebuildEmitterNodes dereference it and hard-crashes the editor
552
+ // (ensure GraphSource != nullptr at NiagaraEmitter.cpp, then an access violation).
553
+ // Every add-path must gate on this instead of trusting the emitter loaded clean.
554
+ static bool EmitterHasGraphSource(UNiagaraEmitter* Emitter, const FGuid& Version)
555
+ {
556
+ if (!Emitter) return false;
557
+ const FVersionedNiagaraEmitterData* Data = Version.IsValid()
558
+ ? Emitter->GetEmitterData(Version)
559
+ : Emitter->GetLatestEmitterData();
560
+ return Data != nullptr && Data->GraphSource != nullptr;
561
+ }
562
+
535
563
  TSharedPtr<FJsonValue> FNiagaraHandlers::AddEmitterToSystem(const TSharedPtr<FJsonObject>& Params)
536
564
  {
537
565
  FString SystemPath;
@@ -591,6 +619,13 @@ TSharedPtr<FJsonValue> FNiagaraHandlers::AddEmitterToSystem(const TSharedPtr<FJs
591
619
  // system's overview graph. This is the same path the editor uses for
592
620
  // "Add Emitter to System" in the Niagara System editor.
593
621
  const FGuid EmitterVersion = Emitter->GetExposedVersion().VersionGuid;
622
+ // Guard: a GraphSource-less emitter crashes AddEmitterToSystem. Fail cleanly.
623
+ if (!EmitterHasGraphSource(Emitter, EmitterVersion))
624
+ {
625
+ return MCPError(FString::Printf(
626
+ TEXT("Emitter '%s' has no GraphSource - it was created empty/invalid and cannot be added to a system (adding it would crash the editor). Recreate it with create_niagara_emitter, which now initializes a valid graph."),
627
+ *EmitterPath));
628
+ }
594
629
  const FGuid HandleId = FNiagaraEditorUtilities::AddEmitterToSystem(*System, *Emitter, EmitterVersion);
595
630
  if (!HandleId.IsValid())
596
631
  {
@@ -1041,11 +1076,23 @@ TSharedPtr<FJsonValue> FNiagaraHandlers::CreateNiagaraSystemFromSpec(const TShar
1041
1076
  const TArray<TSharedPtr<FJsonValue>>* EmittersArr = nullptr;
1042
1077
  Params->TryGetArrayField(TEXT("emitters"), EmittersArr);
1043
1078
 
1044
- auto Created = MCPCreateAssetIdempotentNewObject<UNiagaraSystem>(Name, PackagePath, OptionalString(Params, TEXT("onConflict"), TEXT("skip")), TEXT("NiagaraSystem"));
1079
+ // Create the system through UNiagaraSystemFactoryNew, not a bare NewObject.
1080
+ // A NewObject'd UNiagaraSystem has no system spawn/update scripts and no
1081
+ // editor data; FNiagaraEditorUtilities::AddEmitterToSystem then dereferences
1082
+ // that missing state and hard-crashes the editor (access violation). The
1083
+ // factory's InitializeSystem sets up the scripts + editor data + default
1084
+ // nodes - the same asset the plain `create` action produces. (Mirrors
1085
+ // CreateNiagaraSystem.)
1086
+ UClass* SystemFactoryClass = FindObject<UClass>(nullptr, TEXT("/Script/NiagaraEditor.NiagaraSystemFactoryNew"));
1087
+ UFactory* SystemFactory = SystemFactoryClass
1088
+ ? Cast<UFactory>(NewObject<UObject>(GetTransientPackage(), SystemFactoryClass))
1089
+ : nullptr;
1090
+ auto Created = MCPCreateAssetIdempotent<UNiagaraSystem>(Name, PackagePath, OptionalString(Params, TEXT("onConflict"), TEXT("skip")), TEXT("NiagaraSystem"), SystemFactory);
1045
1091
  if (Created.EarlyReturn) return Created.EarlyReturn;
1046
1092
  UNiagaraSystem* System = Created.Asset;
1047
1093
 
1048
1094
  int32 AddedEmitters = 0;
1095
+ TArray<FString> SkippedEmitters;
1049
1096
  if (EmittersArr)
1050
1097
  {
1051
1098
  for (const TSharedPtr<FJsonValue>& V : *EmittersArr)
@@ -1065,6 +1112,14 @@ TSharedPtr<FJsonValue> FNiagaraHandlers::CreateNiagaraSystemFromSpec(const TShar
1065
1112
  if (!Source) Source = Cast<UNiagaraEmitter>(UEditorAssetLibrary::LoadAsset(EmitterPath));
1066
1113
  if (!Source) continue;
1067
1114
  const FGuid Version = Source->GetExposedVersion().VersionGuid;
1115
+ // Guard: skip GraphSource-less emitters instead of crashing the
1116
+ // editor. Surfaced in the result so the caller knows which ones
1117
+ // failed and can recreate them.
1118
+ if (!EmitterHasGraphSource(Source, Version))
1119
+ {
1120
+ SkippedEmitters.Add(EmitterPath);
1121
+ continue;
1122
+ }
1068
1123
  // #275: route through FNiagaraEditorUtilities so we get
1069
1124
  // KillSystemInstances + RebuildEmitterNodes + unique-name
1070
1125
  // resolution instead of mutating the handle list raw.
@@ -1080,6 +1135,15 @@ TSharedPtr<FJsonValue> FNiagaraHandlers::CreateNiagaraSystemFromSpec(const TShar
1080
1135
  MCPSetCreated(Res);
1081
1136
  Res->SetStringField(TEXT("path"), System->GetPathName());
1082
1137
  Res->SetNumberField(TEXT("emittersAdded"), AddedEmitters);
1138
+ if (SkippedEmitters.Num() > 0)
1139
+ {
1140
+ TArray<TSharedPtr<FJsonValue>> Skipped;
1141
+ for (const FString& S : SkippedEmitters) Skipped.Add(MakeShared<FJsonValueString>(S));
1142
+ Res->SetArrayField(TEXT("skippedEmitters"), Skipped);
1143
+ Res->SetStringField(TEXT("warning"), FString::Printf(
1144
+ TEXT("%d emitter(s) were skipped because they have no GraphSource (created empty/invalid). Recreate them with create_niagara_emitter."),
1145
+ SkippedEmitters.Num()));
1146
+ }
1083
1147
  MCPSetDeleteAssetRollback(Res, System->GetPathName());
1084
1148
  return MCPResult(Res);
1085
1149
  }