ultimate-unreal-engine-mcp 0.1.24 → 0.1.25

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.
@@ -415,4 +415,92 @@ export function registerEditorTools(server, bridge) {
415
415
  }
416
416
  return sendOrDisconnect(_bridge, { type: 'actor.componentBounds', payload });
417
417
  }));
418
+ // --------------------------------------------------------------------------
419
+ // ue_rebuild
420
+ // --------------------------------------------------------------------------
421
+ server.registerTool('ue_rebuild', {
422
+ title: 'Save, Rebuild & Relaunch Editor',
423
+ description: '[requires_plugin] Saves all dirty packages, closes the editor, runs Build.bat, and relaunches the editor. Returns build output and relaunch status.',
424
+ inputSchema: z.object({
425
+ project_path: z.string().describe('Absolute path to the .uproject file'),
426
+ ue_path: z.string().optional().describe('UE engine install path (default: C:/Program Files/Epic Games/UE_5.7)'),
427
+ }),
428
+ annotations: {
429
+ readOnlyHint: false,
430
+ destructiveHint: true,
431
+ },
432
+ }, withKnownIssues('ue_rebuild', async (args) => {
433
+ const { execFileSync, spawn } = await import('child_process');
434
+ const net = await import('net');
435
+ const uePath = args.ue_path ?? 'C:/Program Files/Epic Games/UE_5.7';
436
+ const projectPath = args.project_path;
437
+ const steps = [];
438
+ // Step 1: Save all
439
+ try {
440
+ const saveResult = await _bridge.sendCommand({ type: 'editor.saveAll', correlationId: '' });
441
+ steps.push({ step: 'save', success: saveResult.success });
442
+ }
443
+ catch (err) {
444
+ steps.push({ step: 'save', error: err.message });
445
+ }
446
+ // Step 2: Quit editor
447
+ try {
448
+ await _bridge.sendCommand({ type: 'editor.quit', correlationId: '' });
449
+ steps.push({ step: 'quit', sent: true });
450
+ }
451
+ catch {
452
+ steps.push({ step: 'quit', sent: true, note: 'connection dropped (expected)' });
453
+ }
454
+ // Step 3: Wait for editor to fully close (poll TCP port)
455
+ const waitForClose = () => new Promise((resolve) => {
456
+ let attempts = 0;
457
+ const check = () => {
458
+ const sock = net.createConnection({ port: 55557, host: '127.0.0.1' });
459
+ sock.on('connect', () => { sock.destroy(); attempts++; if (attempts < 60) setTimeout(check, 500); else resolve(false); });
460
+ sock.on('error', () => { resolve(true); });
461
+ };
462
+ setTimeout(check, 1000);
463
+ });
464
+ const closed = await waitForClose();
465
+ steps.push({ step: 'wait_close', closed });
466
+ // Step 4: Build (using execFileSync to avoid shell injection)
467
+ try {
468
+ const buildBat = `${uePath}/Engine/Build/BatchFiles/Build.bat`;
469
+ const buildArgs = ['ScaryRogueEditor', 'Win64', 'Development', `-Project=${projectPath}`, '-WaitMutex'];
470
+ const buildOutput = execFileSync(buildBat, buildArgs, { encoding: 'utf8', timeout: 300000, shell: true });
471
+ const succeeded = buildOutput.includes('Succeeded');
472
+ const lastLines = buildOutput.trim().split('\n').slice(-5).join('\n');
473
+ steps.push({ step: 'build', succeeded, output: lastLines });
474
+ if (!succeeded) {
475
+ return { content: [{ type: 'text', text: JSON.stringify({ steps }) }] };
476
+ }
477
+ }
478
+ catch (err) {
479
+ steps.push({ step: 'build', error: err.message?.slice(0, 500) });
480
+ return { content: [{ type: 'text', text: JSON.stringify({ steps }) }] };
481
+ }
482
+ // Step 5: Relaunch editor
483
+ try {
484
+ const editorExe = `${uePath}/Engine/Binaries/Win64/UnrealEditor.exe`;
485
+ const child = spawn(editorExe, [projectPath], { detached: true, stdio: 'ignore' });
486
+ child.unref();
487
+ steps.push({ step: 'relaunch', pid: child.pid });
488
+ }
489
+ catch (err) {
490
+ steps.push({ step: 'relaunch', error: err.message });
491
+ }
492
+ // Step 6: Wait for editor to reconnect (TCP port available)
493
+ const waitForReconnect = () => new Promise((resolve) => {
494
+ let attempts = 0;
495
+ const check = () => {
496
+ const sock = net.createConnection({ port: 55557, host: '127.0.0.1' });
497
+ sock.on('connect', () => { sock.destroy(); resolve(true); });
498
+ sock.on('error', () => { attempts++; if (attempts < 120) setTimeout(check, 1000); else resolve(false); });
499
+ };
500
+ setTimeout(check, 5000);
501
+ });
502
+ const reconnected = await waitForReconnect();
503
+ steps.push({ step: 'wait_reconnect', reconnected });
504
+ return { content: [{ type: 'text', text: JSON.stringify({ steps }) }] };
505
+ }));
418
506
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ultimate-unreal-engine-mcp",
3
- "version": "0.1.24",
3
+ "version": "0.1.25",
4
4
  "description": "MCP server giving AI assistants full access to Unreal Engine 5.7 projects",
5
5
  "type": "module",
6
6
  "engines": {
@@ -10,6 +10,9 @@
10
10
 
11
11
  #include "Editor.h"
12
12
  #include "Selection.h"
13
+ #include "FileHelpers.h"
14
+ #include "HAL/PlatformMisc.h"
15
+ #include "Containers/Ticker.h"
13
16
  #include "EditorViewportClient.h"
14
17
  #include "LevelEditorViewport.h"
15
18
  #include "EngineUtils.h"
@@ -170,4 +173,46 @@ void RegisterEditorStateCommands(FMCPCommandRouter& Router)
170
173
 
171
174
  SendResponse(BuildEditorStateSuccessResponse(CorrId, Data) + TEXT("\n"));
172
175
  });
176
+
177
+ // -----------------------------------------------------------------------
178
+ // editor.saveAll
179
+ // Saves all dirty packages (levels, assets). Returns count of saved packages.
180
+ // -----------------------------------------------------------------------
181
+ Router.RegisterHandler(TEXT("editor.saveAll"), [](TSharedPtr<FJsonObject> Cmd, FMCPResponseSender SendResponse)
182
+ {
183
+ const FString CorrId = Cmd->GetStringField(TEXT("correlationId"));
184
+
185
+ const bool bSaved = FEditorFileUtils::SaveDirtyPackages(
186
+ false, // bPromptUserToSave
187
+ true, // bSaveMapPackages
188
+ true, // bSaveContentPackages
189
+ false); // bFastSave
190
+
191
+ TSharedPtr<FJsonObject> Data = MakeShared<FJsonObject>();
192
+ Data->SetBoolField(TEXT("saved"), bSaved);
193
+
194
+ SendResponse(BuildEditorStateSuccessResponse(CorrId, Data) + TEXT("\n"));
195
+ });
196
+
197
+ // -----------------------------------------------------------------------
198
+ // editor.quit
199
+ // Gracefully closes the editor. Response is sent before shutdown begins.
200
+ // -----------------------------------------------------------------------
201
+ Router.RegisterHandler(TEXT("editor.quit"), [](TSharedPtr<FJsonObject> Cmd, FMCPResponseSender SendResponse)
202
+ {
203
+ const FString CorrId = Cmd->GetStringField(TEXT("correlationId"));
204
+
205
+ TSharedPtr<FJsonObject> Data = MakeShared<FJsonObject>();
206
+ Data->SetBoolField(TEXT("quitting"), true);
207
+
208
+ SendResponse(BuildEditorStateSuccessResponse(CorrId, Data) + TEXT("\n"));
209
+
210
+ // Defer the actual quit to next tick so the response gets sent first.
211
+ FTSTicker::GetCoreTicker().AddTicker(
212
+ FTickerDelegate::CreateLambda([](float) -> bool
213
+ {
214
+ FPlatformMisc::RequestExit(false); // false = clean shutdown
215
+ return false;
216
+ }), 0.1f);
217
+ });
173
218
  }
@@ -186,6 +186,120 @@ static UObject* CreateFbxFactory(bool bImportMaterials, bool bCombineMeshes, flo
186
186
 
187
187
  void RegisterImportExportCommands(FMCPCommandRouter& Router)
188
188
  {
189
+ // -----------------------------------------------------------------------
190
+ // import.texture
191
+ // Imports a texture file (PNG, JPG, TGA, etc.) to a UE content path.
192
+ // Uses UTextureFactory via reflection. Runs synchronously (no Interchange).
193
+ // Payload: { source_file, dest_path }
194
+ // -----------------------------------------------------------------------
195
+ Router.RegisterHandler(TEXT("import.texture"), [](TSharedPtr<FJsonObject> Cmd, FMCPResponseSender SendResponse)
196
+ {
197
+ const FString CorrId = Cmd->GetStringField(TEXT("correlationId"));
198
+
199
+ TSharedPtr<FJsonObject> Payload;
200
+ const TSharedPtr<FJsonValue>* PayloadVal = Cmd->Values.Find(TEXT("payload"));
201
+ if (PayloadVal && (*PayloadVal)->Type == EJson::Object)
202
+ {
203
+ Payload = (*PayloadVal)->AsObject();
204
+ }
205
+
206
+ FString SourceFile, DestPath;
207
+ if (!Payload.IsValid()
208
+ || !Payload->TryGetStringField(TEXT("source_file"), SourceFile) || SourceFile.IsEmpty()
209
+ || !Payload->TryGetStringField(TEXT("dest_path"), DestPath) || DestPath.IsEmpty())
210
+ {
211
+ SendResponse(BuildImpErrorResponse(CorrId, TEXT("missing_required_fields")) + TEXT("\n"));
212
+ return;
213
+ }
214
+
215
+ if (SourceFile.Contains(TEXT("..")) || !FPaths::FileExists(SourceFile))
216
+ {
217
+ SendResponse(BuildImpErrorResponse(CorrId, TEXT("source_file_not_found")) + TEXT("\n"));
218
+ return;
219
+ }
220
+
221
+ if (!IsValidAssetPath(DestPath))
222
+ {
223
+ SendResponse(BuildImpErrorResponse(CorrId, TEXT("invalid_dest_path")) + TEXT("\n"));
224
+ return;
225
+ }
226
+
227
+ // Split dest into path + name.
228
+ FString PackagePath = DestPath;
229
+ FString AssetName;
230
+ if (DestPath.EndsWith(TEXT("/")))
231
+ {
232
+ AssetName = FPaths::GetBaseFilename(SourceFile);
233
+ PackagePath = DestPath.LeftChop(1);
234
+ }
235
+ else
236
+ {
237
+ AssetName = FPackageName::GetLongPackageAssetName(DestPath);
238
+ PackagePath = FPackageName::GetLongPackagePath(DestPath);
239
+ if (AssetName.IsEmpty())
240
+ {
241
+ AssetName = FPaths::GetBaseFilename(SourceFile);
242
+ PackagePath = DestPath;
243
+ }
244
+ }
245
+
246
+ // Find UTextureFactory via reflection (avoids including engine-specific headers).
247
+ UClass* TextureFactoryClass = FindObject<UClass>(nullptr, TEXT("/Script/UnrealEd.TextureFactory"));
248
+ if (!TextureFactoryClass)
249
+ {
250
+ SendResponse(BuildImpErrorResponse(CorrId, TEXT("texture_factory_not_found")) + TEXT("\n"));
251
+ return;
252
+ }
253
+
254
+ UObject* TextureFactory = NewObject<UObject>(GetTransientPackage(), TextureFactoryClass);
255
+ if (!TextureFactory)
256
+ {
257
+ SendResponse(BuildImpErrorResponse(CorrId, TEXT("factory_creation_failed")) + TEXT("\n"));
258
+ return;
259
+ }
260
+
261
+ // Create import task.
262
+ IAssetTools& AT = FModuleManager::LoadModuleChecked<FAssetToolsModule>(TEXT("AssetTools")).Get();
263
+
264
+ UAssetImportTask* Task = NewObject<UAssetImportTask>();
265
+ Task->Filename = SourceFile;
266
+ Task->DestinationPath = PackagePath;
267
+ Task->DestinationName = AssetName;
268
+ Task->bAutomated = true;
269
+ Task->bReplaceExisting = true;
270
+ Task->bSave = true;
271
+
272
+ // Set the factory.
273
+ FObjectProperty* FactoryProp = CastField<FObjectProperty>(
274
+ Task->GetClass()->FindPropertyByName(TEXT("Factory")));
275
+ if (FactoryProp)
276
+ {
277
+ FactoryProp->SetObjectPropertyValue_InContainer(Task, TextureFactory);
278
+ }
279
+
280
+ // Run import via ticker (same pattern as FBX — avoids task graph recursion).
281
+ FTSTicker::GetCoreTicker().AddTicker(
282
+ FTickerDelegate::CreateLambda([CorrId, SendResponse, Task, &AT](float) -> bool
283
+ {
284
+ TArray<UAssetImportTask*> Tasks;
285
+ Tasks.Add(Task);
286
+ AT.ImportAssetTasks(Tasks);
287
+
288
+ TArray<TSharedPtr<FJsonValue>> AssetsArray;
289
+ for (const FString& ImportedPath : Task->ImportedObjectPaths)
290
+ {
291
+ AssetsArray.Add(MakeShared<FJsonValueString>(ImportedPath));
292
+ }
293
+
294
+ TSharedPtr<FJsonObject> Data = MakeShared<FJsonObject>();
295
+ Data->SetArrayField(TEXT("assets"), AssetsArray);
296
+ Data->SetNumberField(TEXT("count"), static_cast<double>(AssetsArray.Num()));
297
+
298
+ SendResponse(BuildImpSuccessResponse(CorrId, Data) + TEXT("\n"));
299
+ return false;
300
+ }), 0.0f);
301
+ });
302
+
189
303
  // -----------------------------------------------------------------------
190
304
  // import.fbx (IMP-01)
191
305
  // Imports an FBX file to a UE content path.
@@ -208,9 +208,7 @@ static FString TakeScreenshotToFile(int32 Width, int32 Height)
208
208
  return FString();
209
209
  }
210
210
 
211
- // Enable realtime rendering so the viewport continuously renders frames.
212
- // Without this, the editor viewport only renders when "dirty" and
213
- // FScreenshotRequest never gets picked up on subsequent calls.
211
+ // Enable realtime rendering so the viewport renders frames continuously.
214
212
  ViewportClient->SetRealtime(true);
215
213
 
216
214
  const FString Timestamp = FDateTime::Now().ToString(TEXT("%Y%m%d_%H%M%S_%s"));
@@ -220,13 +218,22 @@ static FString TakeScreenshotToFile(int32 Width, int32 Height)
220
218
  GScreenshotResolutionX = Width;
221
219
  GScreenshotResolutionY = Height;
222
220
 
223
- // Invalidate to ensure the viewport redraws with current camera state.
221
+ // Force a complete render cycle BEFORE requesting the screenshot.
222
+ // This ensures the viewport has rendered the current camera state.
223
+ // Without this, subsequent screenshots within the same session fail
224
+ // because FScreenshotRequest only fires once per rendered frame.
224
225
  ViewportClient->Viewport->Invalidate();
226
+ GEditor->RedrawAllViewports();
227
+ FlushRenderingCommands();
225
228
 
226
- // Request screenshot to a specific file path. With realtime enabled,
227
- // the renderer will process this on the next frame.
229
+ // Now request the screenshot the next frame render will capture it.
228
230
  FScreenshotRequest::RequestScreenshot(FilePath, false /* bShowUI */, false /* bAddFilenameSuffix */);
229
231
 
232
+ // Force another render to actually capture the screenshot.
233
+ ViewportClient->Viewport->Invalidate();
234
+ GEditor->RedrawAllViewports();
235
+ FlushRenderingCommands();
236
+
230
237
  return FilePath;
231
238
  }
232
239