Unified, portable Unreal Engine MCP system plugin

Merge of the two project copies into one self-contained plugin (the superset:
variable_op + variables.py, full pcg_op runtime/declarative/preset ops, the
CreateWidgetFloatAnimation widget tool, and full Voxel graph authoring).

Made fully project- and machine-agnostic — no hardcoded paths:
- New src/projectPaths.js auto-detects the host .uproject (walk-up), project
  name, Editor build target, log file, and engine install (EngineAssociation
  via launcher manifest/registry, else installed-engine scan). All overridable
  via UE_* env vars.
- Rewired buildOrchestrator/insights/launcher/insightsExporter/server.js and the
  Python workers (cpp_scaffold, live_coding, apply_graph, console) off the old
  C:/Github/ihy, IHY*, E:/UE_Versions and UE_5.7 literals onto the resolver.
- Voxel made optional: Build.cs auto-detects the Voxel plugin (env
  UE_MCP_WITH_VOXEL override) and the C++ compiles to stubs under WITH_MCP_VOXEL,
  so the module builds in projects without Voxel; .uplugin marks Voxel optional.
- De-branded the agent-gateway and docs; scrubbed a leaked API key; excluded
  node_modules/Binaries/Intermediate/__pycache__/secrets from the repo.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Bonchellon
2026-06-24 01:07:24 +03:00
parent d46e03e53e
commit eba71c4ca8
76 changed files with 23838 additions and 1 deletions

17
.gitignore vendored
View File

@ -74,3 +74,20 @@ Plugins/**/Intermediate/*
# Cache files for the editor to use
DerivedDataCache/*
# ---> MCP server / Node.js
node_modules/
npm-debug.log*
yarn-error.log*
# ---> Python workers
__pycache__/
*.pyc
# ---> Secrets & local config (NEVER commit these)
# auth token for the agent gateway — copy auth.env.example to auth.env locally
auth.env
agent-gateway/auth.env
agent-gateway/.generated-audio/
# real API keys live in a local, untracked copy
integrations.local.json

173
FUTURE_MCP_TOOLS.md Normal file
View File

@ -0,0 +1,173 @@
# UEBlueprintMCP — TODO / Future Tool Coverage
Domains still missing from the MCP surface. Ordered by likely value.
## High value (likely soon)
### pcg_op — mesh-entry & volume helpers (gap found building a PCG graph)
- `set_mesh_entries{asset_path, node, entries:[{mesh, weight?}]}` — populate a
PCGStaticMeshSpawner's weighted selector. Today this needs raw py: the
`mesh_selector_parameters` instance is READ-ONLY (can't reassign), so you must
mutate it in place — `msp.set_editor_property("mesh_entries", [...])` where each
entry is a `PCGMeshSelectorWeightedEntry` and the mesh goes on
`entry.descriptor.static_mesh` (struct is value-copy → set descriptor back).
- `set_node_property` struct coercion already handles Vector vs Rotator (3-num
list, Vector tried then Rotator(roll,pitch,yaw)) — extend to Vector2D/Color/
Transform as needed.
- `spawn_pcg_volume{graph, location, scale, name?}` + `generate{actor}` — wrap
PCGVolume spawn + `PCGComponent.set_graph` + `generate(True)`. Note: partitioned
worlds route instances to PCGPartitionActors async; count there, not on the volume.
### Sequencer / Cinematics
- create_level_sequence{path}
- add_binding{seq, actor_name}, remove_binding{seq, guid}
- add_track{seq, binding_guid, track_class} (Transform, Float, Visibility, ...)
- add_key{seq, track, time, value}
- set_playback_range{seq, start, end}
- bake_to_anim_sequence{seq, binding}
- spawn_camera_actor{location, rotation, fov?}
- export_video / export_image_sequence (MovieRenderQueue)
### GAS — Gameplay Ability System
- create_gameplay_ability_bp{path, parent?}
- create_attribute_set_bp{path}
- create_gameplay_effect_bp{path}
- add_gameplay_tag{tag}, remove_gameplay_tag{tag}, list_gameplay_tags{prefix?}
- gameplay_cue_create{path}
### Data-driven assets
- create_gameplay_tags_table{path}
- data_table_add_row{path, row_name, fields}
- data_table_set_cell{path, row, column, value}
- data_table_read{path, row?}
- curve_table_create / add_curve
- data_asset_set_property{path, property, value}
- enum_add_value, struct_add_field
### Physics / Chaos
- create_physics_asset{path, skeletal_mesh}
- create_physical_material{path, friction?, restitution?, density?}
- physics_constraint_set{component, properties}
- chaos_destruction_field_spawn
### Landscape / Foliage / World Partition
- landscape_create{section_size, components_per_section, ...}
- landscape_apply_material{path}
- landscape_paint_layer{layer_name, brush_op:'add|remove', position, radius}
- foliage_add_type{static_mesh, density?, ...}
- foliage_paint{location, radius}
- world_partition_set_loading_range{actor, range}
- hlod_build{level_path}
### Lighting
- build_lighting{quality:'preview|medium|high|production'}
- set_lumen{enabled, software_raytracing?, hardware_raytracing?}
- set_vsm{enabled, resolution?}
- light_set_property{actor_name, property, value}
### PIE (Play-In-Editor) control
- pie_play{mode:'selected|standalone|spawn_player', num_players?}
- pie_stop{}
- pie_pause / pie_resume
- pie_eject
- pie_screenshot{path}
- pie_console_command_in_session{command}
- pie_get_log{tail?, errors_only?}
### Editor / Project settings
- ini_get{config:'Engine|Game|Editor|Input', section, key}
- ini_set{config, section, key, value}
- plugin_enable{name} / plugin_disable{name} / plugin_list{}
- platform_target_settings{platform, key, value}
### Source Control (Perforce/Git/Plastic)
- sc_status{paths}
- sc_checkout{paths}
- sc_revert{paths}
- sc_submit{paths, description}
- sc_get_user_settings
### Cook / Package / Build
- cook_project{platform, maps?, options?}
- package_project{platform, build_config:'Development|Shipping', output_dir}
- build_lighting_then_save{level}
- check_cook_errors{platform}
## Medium value
### Niagara — extending the existing niagara_op
- set_module_input (currently stubbed) — write parameters into a module
- promote_to_user_parameter
- niagara_simulation_cache_record / play
### UMG — extending widget_op
- bind_property{widget, property, function_path}
- create_animation{widget, name, duration}
- add_animation_track{...}
- compile_and_preview
### Materials — extending material_op
- material_function_create
- material_layer_create / material_layer_blend_create
- substrate_topology helpers (if Substrate enabled)
- material_parameter_collection_create
### Localization
- loc_target_create / loc_gather / loc_compile
- text_add_to_target{namespace, key, source}
### Replication / Networking helpers
- bp_set_replication{bp_path, replicates?, replicates_movement?}
- bp_variable_set_replication{bp_path, name, replication:'None|Replicated|RepNotify'}
### Online subsystem / OSS / EOS
- launch_dedicated_pie_server
- set_steam_appid / set_eos_credentials
### Editor utility / Pipeline
- editor_utility_widget_run{path}
- editor_utility_blueprint_run{path, function}
- exec_python_file{path}
- exec_python_inline{code} — gated, dev-only
### Test / Automation
- automation_run{tests:'filter'}
- gauntlet_run{config}
- unit_test_blueprint{bp_path}
## Low priority / nice-to-have
- Composure (in-camera VFX)
- USD import/export
- Datasmith
- Pixel Streaming setup
- nDisplay
- VR/AR specific (OpenXR session, head tracking)
- Web Browser widget
- Movie Render Queue presets
- AI behavior debugging (LogVisualizer)
- Replay / DemoNetDriver
## Architecture improvements
- **Common helpers module** (`_common.py`) for `_tee`, `_load`, `_split_pkg`, `_vec`, `_rot` — every module currently duplicates these.
- **Versioned envelope** — include schema_version in every result for client compatibility.
- **Streaming results** — for long-running ops (cook, lighting build), stream progress via a separate channel instead of one blocking call.
- **Auto-discovery of ops** — generate the JSON schemas in server.js from python module introspection, so adding an op in python automatically exposes it.
- **Per-op permissions** — read-only vs mutating tag in schema so a client policy can block destructive ops.
- **Undo grouping** — wrap each entrypoint in `unreal.ScopedEditorTransaction` so every mutation is one undo step.
## BUG: apply_graph `spawn_actor` kind hard-crashes the editor (2026-06-22)
`apply_graph` node kind `spawn_actor``UMCPGraphLibrary::SpawnSpawnActorFromClassNode()`
(`MCPGraphLibrary.cpp:716`) calls `PlaceNode<UK2Node_SpawnActorFromClass>`, whose blind
`AllocateDefaultPins()` trips `check(Result)` in `EdGraphNode.h:586` and **takes the whole
editor down** (assertion → StaticShutdownAfterError). Reproduced 3x.
Workaround used: spawn from C++ instead (`UWorld::SpawnActor`) exposed via a
`UBlueprintFunctionLibrary`, or build the deferred-spawn pair manually with `call_function`
nodes (`GameplayStatics.BeginDeferredActorSpawnFromClass` + `FinishSpawningActor`).
Proper fix: construct `UK2Node_SpawnActorFromClass` via `FGraphNodeCreator<>` /
set the Class pin before `AllocateDefaultPins`, or guard the assert. Until fixed, **do not
use the `spawn_actor` kind**.

120
README.md
View File

@ -1,2 +1,120 @@
# unreal-engine-mcp-system-plugin
# Unreal Engine MCP System Plugin
A drop-in Unreal Engine **editor plugin** that exposes the UE editor to an
[MCP](https://modelcontextprotocol.io) client (Claude Code, Cursor, etc.) as a
rich set of tools: Blueprint graph CRUD, UMG/Slate authoring with a screenshot
vision loop, materials, Niagara, PCG (incl. PCGEx), assets, member variables,
C++ scaffolding & rebuild, headless asset/scene capture, Unreal Insights
analysis, the Project Launcher, and more.
It has two halves that ship together:
| Part | Lives in | What it is |
|------|----------|------------|
| **C++ editor module** | `Source/UEBlueprintMCPEditor/` | `UEBlueprintMCPEditor` — Blueprint function libraries the Python workers call. |
| **MCP server** | `src/` (Node.js) + `ue_side/` (Python) | A stdio MCP server that drives the editor over Remote Control. |
> **Portable by design.** Nothing is hardcoded to a project, user, or machine.
> The server discovers the host `.uproject`, project name, Editor build target,
> log file, and engine install automatically from where the plugin sits on disk.
> Every value can still be overridden with an environment variable (below).
---
## Install
1. Clone (or submodule) this repo into your project's `Plugins/` folder. The
folder name doesn't matter to UE — the plugin is identified by
`UEBlueprintMCP.uplugin`:
```
<YourProject>/Plugins/UEBlueprintMCP/ <- this repo
```
2. Enable the engine plugins it builds against (most are on by default):
**Python Editor Script Plugin**, **Remote Control API**, **Niagara**.
*(Voxel is optional — see below.)*
3. Generate project files and build the editor (the plugin has a C++ module).
4. In the editor, enable **Remote Control** and start its web server
(`WebControl.StartServer`, or set `WebControl.EnableServerOnStartup=true`).
5. Install the MCP server deps:
```bash
cd Plugins/UEBlueprintMCP
npm install
```
6. Point your MCP client at `src/server.js`, e.g.:
```json
{
"mcpServers": {
"ue-blueprint": {
"command": "node",
"args": ["<abs-path>/Plugins/UEBlueprintMCP/src/server.js"]
}
}
}
```
Call `ping_ue` first to confirm the editor is reachable.
---
## Zero-config path resolution
`src/projectPaths.js` resolves everything from the plugin's location:
- **Project** — walks up from the plugin to the nearest `*.uproject`.
- **Project name / Editor target / log file** — derived from that `.uproject`
(`<Name>`, `<Name>Editor`, `Saved/Logs/<Name>.log`).
- **Engine** — read from the project's `EngineAssociation`, resolved via the
Epic launcher manifest / registry, falling back to a scan of installed
engines.
### Environment overrides
| Variable | Overrides |
|----------|-----------|
| `UE_PROJECT_DIR` | the `.uproject` parent folder |
| `UE_UPROJECT_NAME` | e.g. `MyGame.uproject` |
| `UE_BUILD_TARGET` | e.g. `MyGameEditor` |
| `UE_BUILD_CONFIG` | build config (default `Development`) |
| `UE_ENGINE_DIR` | engine root (the folder containing `/Engine`) |
| `UE_INSIGHTS_EXE` | full path to `UnrealInsights.exe` |
| `UE_REMOTE_CONTROL_URL` | Remote Control base URL (default `http://127.0.0.1:30010`) |
| `UE_MCP_WITH_VOXEL` | `1`/`0` to force the optional Voxel integration on/off |
---
## Optional: Voxel integration
The `voxel_graph_op` tool authors [Voxel Plugin](https://voxelplugin.com) graphs.
Voxel is heavy and most projects don't have it, so the plugin **compiles with or
without it**:
- The build auto-detects the Voxel plugin. If present, voxel support is enabled
(`WITH_MCP_VOXEL=1`); otherwise the C++ compiles to stubs and `voxel_graph_op`
returns a "Voxel not enabled" error.
- Force it with `UE_MCP_WITH_VOXEL=1` (errors if Voxel is missing) or disable
with `UE_MCP_WITH_VOXEL=0`.
---
## Optional: in-engine agent gateway
`agent-gateway/` is a small HTTP gateway that lets an in-engine WebBrowser widget
talk to the MCP server through the Claude Agent SDK. It is **not required** for
MCP clients — see `agent-gateway/README.md`. Copy `agent-gateway/auth.env.example`
to `agent-gateway/auth.env` and add your token; that file is git-ignored.
---
## Docs
- `UI_WORKFLOW.md` — the mandatory protocol for any UMG/UI work (theme tokens,
components, the screenshot vision loop).
- `FUTURE_MCP_TOOLS.md` — backlog of tool ideas.

View File

@ -0,0 +1,202 @@
#include "MCPCaptureLibrary.h"
#include "CanvasTypes.h"
#include "Components/SceneCaptureComponent2D.h"
#include "Editor.h"
#include "Engine/TextureRenderTarget2D.h"
#include "Engine/World.h"
#include "HAL/PlatformFileManager.h"
#include "IImageWrapper.h"
#include "IImageWrapperModule.h"
#include "Misc/App.h"
#include "Misc/FileHelper.h"
#include "Misc/Paths.h"
#include "Modules/ModuleManager.h"
#include "ObjectTools.h"
#include "RenderingThread.h"
#include "Misc/ObjectThumbnail.h"
namespace
{
// Encode an array of BGRA8 pixels to a PNG on disk.
bool SaveBGRAToPNG(
const uint8* BGRA,
int32 NumBytes,
int32 Width,
int32 Height,
const FString& OutputAbsPath,
const FString& FileNameNoExt,
FString& OutFullPath,
FString& OutError)
{
if (!BGRA || NumBytes < Width * Height * 4)
{
OutError = TEXT("empty / undersized pixel buffer");
return false;
}
IImageWrapperModule& Mod =
FModuleManager::LoadModuleChecked<IImageWrapperModule>(FName("ImageWrapper"));
TSharedPtr<IImageWrapper> PNG = Mod.CreateImageWrapper(EImageFormat::PNG);
if (!PNG.IsValid() ||
!PNG->SetRaw(BGRA, Width * Height * 4, Width, Height, ERGBFormat::BGRA, 8))
{
OutError = TEXT("PNG SetRaw failed");
return false;
}
const TArray64<uint8>& Compressed = PNG->GetCompressed(100);
IPlatformFile& PF = FPlatformFileManager::Get().GetPlatformFile();
if (!PF.DirectoryExists(*OutputAbsPath))
{
PF.CreateDirectoryTree(*OutputAbsPath);
}
const FString FullPath = FPaths::Combine(OutputAbsPath, FileNameNoExt + TEXT(".png"));
if (!FFileHelper::SaveArrayToFile(Compressed, *FullPath))
{
OutError = FString::Printf(TEXT("SaveArrayToFile failed: %s"), *FullPath);
return false;
}
OutFullPath = FullPath;
return true;
}
}
bool UMCPCaptureLibrary::RenderAssetThumbnailToPNG(
const FString& AssetPath,
const FString& OutputAbsPath,
const FString& FileNameNoExt,
int32 Width,
int32 Height,
FString& OutFullPath,
FString& OutError)
{
OutFullPath.Reset();
OutError.Reset();
const int32 W = FMath::Clamp(Width, 16, 2048);
const int32 H = FMath::Clamp(Height, 16, 2048);
UObject* Object = StaticLoadObject(UObject::StaticClass(), nullptr, *AssetPath);
if (!Object)
{
OutError = FString::Printf(TEXT("could not load asset: %s"), *AssetPath);
return false;
}
// Render the asset through its registered thumbnail renderer. This is exactly
// what the Content Browser uses, so it works for every asset type and needs
// no open editor. RenderThumbnail allocates its own render target when we
// pass null and fills the thumbnail with uncompressed BGRA8 pixels.
FObjectThumbnail Thumbnail;
ThumbnailTools::RenderThumbnail(
Object,
static_cast<uint32>(W),
static_cast<uint32>(H),
ThumbnailTools::EThumbnailTextureFlushMode::AlwaysFlush,
nullptr,
&Thumbnail);
const TArray<uint8>& Bytes = Thumbnail.GetUncompressedImageData();
if (Bytes.Num() == 0)
{
OutError = FString::Printf(
TEXT("thumbnail renderer produced no pixels for %s (no renderer for this asset type?)"),
*AssetPath);
return false;
}
return SaveBGRAToPNG(
Bytes.GetData(),
Bytes.Num(),
Thumbnail.GetImageWidth(),
Thumbnail.GetImageHeight(),
OutputAbsPath,
FileNameNoExt,
OutFullPath,
OutError);
}
bool UMCPCaptureLibrary::CaptureEditorSceneToPNG(
FVector Location,
FRotator Rotation,
float FOV,
const FString& OutputAbsPath,
const FString& FileNameNoExt,
int32 Width,
int32 Height,
FString& OutFullPath,
FString& OutError)
{
OutFullPath.Reset();
OutError.Reset();
UWorld* World = GEditor ? GEditor->GetEditorWorldContext().World() : nullptr;
if (!World)
{
OutError = TEXT("no editor world available");
return false;
}
const int32 W = FMath::Clamp(Width, 16, 4096);
const int32 H = FMath::Clamp(Height, 16, 4096);
UTextureRenderTarget2D* RT = NewObject<UTextureRenderTarget2D>(GetTransientPackage());
RT->ClearColor = FLinearColor::Black;
RT->TargetGamma = 2.2f;
RT->InitCustomFormat(W, H, PF_B8G8R8A8, /*bForceLinearGamma=*/false);
RT->UpdateResourceImmediate(true);
// Transient capture component bound to the editor world — no actor, no viewport.
USceneCaptureComponent2D* Capture =
NewObject<USceneCaptureComponent2D>(World->GetWorldSettings());
if (!Capture)
{
OutError = TEXT("failed to create SceneCaptureComponent2D");
return false;
}
Capture->TextureTarget = RT;
Capture->CaptureSource = ESceneCaptureSource::SCS_FinalColorLDR;
Capture->FOVAngle = FMath::Clamp(FOV, 5.f, 170.f);
Capture->bCaptureEveryFrame = false;
Capture->bCaptureOnMovement = false;
Capture->bAlwaysPersistRenderingState = true;
Capture->RegisterComponentWithWorld(World);
Capture->SetWorldLocationAndRotation(Location, Rotation);
Capture->CaptureScene();
FlushRenderingCommands();
FTextureRenderTargetResource* RTResource = RT->GameThread_GetRenderTargetResource();
bool bOk = false;
if (RTResource)
{
TArray<FColor> Pixels;
Pixels.SetNumUninitialized(W * H);
FReadSurfaceDataFlags ReadFlags(RCM_UNorm);
ReadFlags.SetLinearToGamma(false);
if (RTResource->ReadPixels(Pixels, ReadFlags))
{
// FColor is BGRA in memory.
bOk = SaveBGRAToPNG(
reinterpret_cast<const uint8*>(Pixels.GetData()),
Pixels.Num() * sizeof(FColor),
W, H, OutputAbsPath, FileNameNoExt, OutFullPath, OutError);
}
else
{
OutError = TEXT("ReadPixels failed");
}
}
else
{
OutError = TEXT("RT resource null after capture");
}
// Tear down the transient component.
Capture->UnregisterComponent();
Capture->DestroyComponent();
return bOk;
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,338 @@
#include "MCPMaterialLibrary.h"
#include "Dom/JsonObject.h"
#include "Dom/JsonValue.h"
#include "Materials/Material.h"
#include "Materials/MaterialExpression.h"
#include "Materials/MaterialFunction.h"
#include "Serialization/JsonSerializer.h"
#include "Serialization/JsonWriter.h"
namespace
{
FString JsonStringifyMaterial(const TSharedRef<FJsonObject>& Object)
{
FString Out;
const TSharedRef<TJsonWriter<>> Writer = TJsonWriterFactory<>::Create(&Out);
FJsonSerializer::Serialize(Object, Writer);
return Out;
}
FString ExpressionId(const UMaterialExpression* Expression)
{
if (!Expression)
{
return FString();
}
#if WITH_EDITORONLY_DATA
if (Expression->MaterialExpressionGuid.IsValid())
{
return Expression->MaterialExpressionGuid.ToString(EGuidFormats::DigitsWithHyphensInBraces);
}
#endif
return Expression->GetPathName();
}
FString OutputNameForIndex(UMaterialExpression* Expression, int32 OutputIndex)
{
if (!Expression || OutputIndex < 0)
{
return FString();
}
TArray<FExpressionOutput>& Outputs = Expression->GetOutputs();
if (!Outputs.IsValidIndex(OutputIndex))
{
return FString();
}
return Outputs[OutputIndex].OutputName.ToString();
}
int32 ResolveOutputIndex(UMaterialExpression* Expression, const FString& OutputNameOrIndex)
{
if (!Expression)
{
return INDEX_NONE;
}
FString Needle = OutputNameOrIndex;
Needle.TrimStartAndEndInline();
if (Needle.IsEmpty() || Needle.Equals(TEXT("None"), ESearchCase::IgnoreCase))
{
return 0;
}
int32 NumericIndex = INDEX_NONE;
if (LexTryParseString(NumericIndex, *Needle))
{
return Expression->GetOutputs().IsValidIndex(NumericIndex) ? NumericIndex : INDEX_NONE;
}
TArray<FExpressionOutput>& Outputs = Expression->GetOutputs();
for (int32 OutputIndex = 0; OutputIndex < Outputs.Num(); ++OutputIndex)
{
if (Outputs[OutputIndex].OutputName.ToString().Equals(Needle, ESearchCase::IgnoreCase))
{
return OutputIndex;
}
}
return Outputs.Num() == 1 ? 0 : INDEX_NONE;
}
int32 ResolveInputIndex(UMaterialExpression* Expression, const FString& InputNameOrIndex)
{
if (!Expression)
{
return INDEX_NONE;
}
FString Needle = InputNameOrIndex;
Needle.TrimStartAndEndInline();
if (Needle.IsEmpty() || Needle.Equals(TEXT("None"), ESearchCase::IgnoreCase))
{
return Expression->CountInputs() == 1 ? 0 : INDEX_NONE;
}
int32 NumericIndex = INDEX_NONE;
if (LexTryParseString(NumericIndex, *Needle))
{
return NumericIndex >= 0 && NumericIndex < Expression->CountInputs() ? NumericIndex : INDEX_NONE;
}
for (int32 InputIndex = 0; InputIndex < Expression->CountInputs(); ++InputIndex)
{
if (Expression->GetInputName(InputIndex).ToString().Equals(Needle, ESearchCase::IgnoreCase))
{
return InputIndex;
}
}
return INDEX_NONE;
}
TSharedRef<FJsonObject> MakeStatusJson(bool bOk, const FString& Error = FString())
{
TSharedRef<FJsonObject> Root = MakeShared<FJsonObject>();
Root->SetBoolField(TEXT("ok"), bOk);
if (!Error.IsEmpty())
{
Root->SetStringField(TEXT("error"), Error);
}
return Root;
}
void AppendExpressionJson(UMaterialExpression* Expression, TArray<TSharedPtr<FJsonValue>>& Nodes, TArray<TSharedPtr<FJsonValue>>& Edges)
{
if (!Expression)
{
return;
}
TSharedRef<FJsonObject> Node = MakeShared<FJsonObject>();
Node->SetStringField(TEXT("guid"), ExpressionId(Expression));
Node->SetStringField(TEXT("name"), Expression->GetName());
Node->SetStringField(TEXT("class"), Expression->GetClass()->GetPathName());
#if WITH_EDITORONLY_DATA
Node->SetNumberField(TEXT("x"), Expression->MaterialExpressionEditorX);
Node->SetNumberField(TEXT("y"), Expression->MaterialExpressionEditorY);
if (!Expression->Desc.IsEmpty())
{
Node->SetStringField(TEXT("desc"), Expression->Desc);
}
#endif
TArray<TSharedPtr<FJsonValue>> Inputs;
const int32 InputCount = Expression->CountInputs();
for (int32 InputIndex = 0; InputIndex < InputCount; ++InputIndex)
{
const FExpressionInput* Input = Expression->GetInput(InputIndex);
const FName InputName = Expression->GetInputName(InputIndex);
TSharedRef<FJsonObject> InputJson = MakeShared<FJsonObject>();
InputJson->SetStringField(TEXT("name"), InputName.ToString());
InputJson->SetNumberField(TEXT("index"), InputIndex);
InputJson->SetBoolField(TEXT("connected"), Input && Input->Expression);
Inputs.Add(MakeShared<FJsonValueObject>(InputJson));
if (Input && Input->Expression)
{
TSharedRef<FJsonObject> Edge = MakeShared<FJsonObject>();
TArray<TSharedPtr<FJsonValue>> From;
From.Add(MakeShared<FJsonValueString>(ExpressionId(Input->Expression)));
From.Add(MakeShared<FJsonValueString>(OutputNameForIndex(Input->Expression, Input->OutputIndex)));
TArray<TSharedPtr<FJsonValue>> To;
To.Add(MakeShared<FJsonValueString>(ExpressionId(Expression)));
To.Add(MakeShared<FJsonValueString>(InputName.ToString()));
Edge->SetArrayField(TEXT("from"), From);
Edge->SetArrayField(TEXT("to"), To);
Edges.Add(MakeShared<FJsonValueObject>(Edge));
}
}
Node->SetArrayField(TEXT("inputs"), Inputs);
TArray<TSharedPtr<FJsonValue>> OutputsJson;
TArray<FExpressionOutput>& Outputs = Expression->GetOutputs();
for (int32 OutputIndex = 0; OutputIndex < Outputs.Num(); ++OutputIndex)
{
TSharedRef<FJsonObject> OutputJson = MakeShared<FJsonObject>();
OutputJson->SetStringField(TEXT("name"), Outputs[OutputIndex].OutputName.ToString());
OutputJson->SetNumberField(TEXT("index"), OutputIndex);
OutputsJson.Add(MakeShared<FJsonValueObject>(OutputJson));
}
Node->SetArrayField(TEXT("outputs"), OutputsJson);
Nodes.Add(MakeShared<FJsonValueObject>(Node));
}
}
TArray<UMaterialExpression*> UMCPMaterialLibrary::GetMaterialExpressions(UObject* MaterialOrFunction)
{
TArray<UMaterialExpression*> Out;
if (UMaterial* Material = Cast<UMaterial>(MaterialOrFunction))
{
for (TObjectPtr<UMaterialExpression> Expression : Material->GetExpressionCollection().Expressions)
{
if (Expression)
{
Out.Add(Expression.Get());
}
}
}
else if (UMaterialFunction* Function = Cast<UMaterialFunction>(MaterialOrFunction))
{
for (TObjectPtr<UMaterialExpression> Expression : Function->GetExpressionCollection().Expressions)
{
if (Expression)
{
Out.Add(Expression.Get());
}
}
}
return Out;
}
UMaterialExpression* UMCPMaterialLibrary::FindMaterialExpression(UObject* MaterialOrFunction, const FString& NodeId)
{
for (UMaterialExpression* Expression : GetMaterialExpressions(MaterialOrFunction))
{
if (!Expression)
{
continue;
}
if (ExpressionId(Expression) == NodeId || Expression->GetName() == NodeId || Expression->GetPathName() == NodeId)
{
return Expression;
}
}
return nullptr;
}
FString UMCPMaterialLibrary::DumpMaterialGraphToJson(UObject* MaterialOrFunction)
{
TSharedRef<FJsonObject> Root = MakeShared<FJsonObject>();
if (!MaterialOrFunction)
{
Root->SetBoolField(TEXT("ok"), false);
Root->SetStringField(TEXT("error"), TEXT("null material/function"));
return JsonStringifyMaterial(Root);
}
Root->SetBoolField(TEXT("ok"), true);
Root->SetStringField(TEXT("asset"), MaterialOrFunction->GetPathName());
Root->SetStringField(TEXT("class"), MaterialOrFunction->GetClass()->GetPathName());
TArray<TSharedPtr<FJsonValue>> Nodes;
TArray<TSharedPtr<FJsonValue>> Edges;
for (UMaterialExpression* Expression : GetMaterialExpressions(MaterialOrFunction))
{
AppendExpressionJson(Expression, Nodes, Edges);
}
Root->SetArrayField(TEXT("nodes"), Nodes);
Root->SetArrayField(TEXT("edges"), Edges);
if (UMaterial* Material = Cast<UMaterial>(MaterialOrFunction))
{
TSharedRef<FJsonObject> Outputs = MakeShared<FJsonObject>();
for (int32 PropertyIndex = 0; PropertyIndex < static_cast<int32>(MP_MAX); ++PropertyIndex)
{
const EMaterialProperty Property = static_cast<EMaterialProperty>(PropertyIndex);
FExpressionInput* Input = Material->GetExpressionInputForProperty(Property);
if (!Input || !Input->Expression)
{
continue;
}
TSharedRef<FJsonObject> Ref = MakeShared<FJsonObject>();
TArray<TSharedPtr<FJsonValue>> From;
From.Add(MakeShared<FJsonValueString>(ExpressionId(Input->Expression)));
From.Add(MakeShared<FJsonValueString>(OutputNameForIndex(Input->Expression, Input->OutputIndex)));
Ref->SetArrayField(TEXT("from"), From);
Outputs->SetObjectField(UEnum::GetValueAsString(Property), Ref);
}
Root->SetObjectField(TEXT("outputs"), Outputs);
}
return JsonStringifyMaterial(Root);
}
FString UMCPMaterialLibrary::ConnectMaterialExpressionsRaw(UObject* MaterialOrFunction, UMaterialExpression* FromExpression, const FString& FromOutput, UMaterialExpression* ToExpression, const FString& ToInput)
{
if (!MaterialOrFunction)
{
return JsonStringifyMaterial(MakeStatusJson(false, TEXT("null material/function")));
}
if (!FromExpression)
{
return JsonStringifyMaterial(MakeStatusJson(false, TEXT("null source expression")));
}
if (!ToExpression)
{
return JsonStringifyMaterial(MakeStatusJson(false, TEXT("null target expression")));
}
const int32 OutputIndex = ResolveOutputIndex(FromExpression, FromOutput);
if (OutputIndex == INDEX_NONE)
{
return JsonStringifyMaterial(MakeStatusJson(false, FString::Printf(TEXT("source output not found: %s.%s"), *FromExpression->GetName(), *FromOutput)));
}
const int32 InputIndex = ResolveInputIndex(ToExpression, ToInput);
if (InputIndex == INDEX_NONE)
{
return JsonStringifyMaterial(MakeStatusJson(false, FString::Printf(TEXT("target input not found: %s.%s"), *ToExpression->GetName(), *ToInput)));
}
FExpressionInput* Input = ToExpression->GetInput(InputIndex);
if (!Input)
{
return JsonStringifyMaterial(MakeStatusJson(false, FString::Printf(TEXT("target input is null: %s.%s"), *ToExpression->GetName(), *ToInput)));
}
Input->Expression = FromExpression;
Input->OutputIndex = OutputIndex;
if (UMaterial* Material = Cast<UMaterial>(MaterialOrFunction))
{
Material->PreEditChange(nullptr);
Material->PostEditChange();
Material->MarkPackageDirty();
}
else if (UMaterialFunction* Function = Cast<UMaterialFunction>(MaterialOrFunction))
{
Function->PreEditChange(nullptr);
Function->PostEditChange();
Function->MarkPackageDirty();
}
TSharedRef<FJsonObject> Root = MakeStatusJson(true);
Root->SetStringField(TEXT("from"), FromExpression->GetName());
Root->SetStringField(TEXT("from_output"), OutputNameForIndex(FromExpression, OutputIndex));
Root->SetStringField(TEXT("to"), ToExpression->GetName());
Root->SetStringField(TEXT("to_input"), ToExpression->GetInputName(InputIndex).ToString());
return JsonStringifyMaterial(Root);
}

View File

@ -0,0 +1,275 @@
#include "MCPNiagaraLibrary.h"
#include "AssetToolsModule.h"
#include "IAssetTools.h"
#include "AssetRegistry/AssetData.h"
#include "Modules/ModuleManager.h"
#include "UObject/Package.h"
#include "UObject/UObjectGlobals.h"
#include "NiagaraSystem.h"
#include "NiagaraSystemFactoryNew.h"
#include "NiagaraEmitter.h"
#include "NiagaraEmitterHandle.h"
#include "NiagaraScript.h"
#include "NiagaraRendererProperties.h"
#include "NiagaraScriptSource.h"
#include "NiagaraGraph.h"
#include "NiagaraNodeOutput.h"
#include "NiagaraNodeFunctionCall.h"
#include "NiagaraCommon.h"
#include "ViewModels/Stack/NiagaraStackGraphUtilities.h"
#include "NiagaraEditorUtilities.h"
namespace
{
struct FStackLocation
{
UNiagaraNodeOutput* OutputNode = nullptr;
bool bValid() const { return OutputNode != nullptr; }
};
ENiagaraScriptUsage ResolveUsage(const FString& GroupName, bool& bOutIsSystem)
{
bOutIsSystem = false;
const FString G = GroupName.TrimStartAndEnd();
if (G.Equals(TEXT("EmitterSpawn"), ESearchCase::IgnoreCase)) return ENiagaraScriptUsage::EmitterSpawnScript;
if (G.Equals(TEXT("EmitterUpdate"), ESearchCase::IgnoreCase)) return ENiagaraScriptUsage::EmitterUpdateScript;
if (G.Equals(TEXT("ParticleSpawn"), ESearchCase::IgnoreCase)) return ENiagaraScriptUsage::ParticleSpawnScript;
if (G.Equals(TEXT("ParticleUpdate"), ESearchCase::IgnoreCase)) return ENiagaraScriptUsage::ParticleUpdateScript;
if (G.Equals(TEXT("SystemSpawn"), ESearchCase::IgnoreCase)) { bOutIsSystem = true; return ENiagaraScriptUsage::SystemSpawnScript; }
if (G.Equals(TEXT("SystemUpdate"), ESearchCase::IgnoreCase)) { bOutIsSystem = true; return ENiagaraScriptUsage::SystemUpdateScript; }
return ENiagaraScriptUsage::ParticleSpawnScript;
}
UNiagaraNodeOutput* FindOutputNodeFromScript(UNiagaraScript* Script, ENiagaraScriptUsage Usage)
{
if (!Script) return nullptr;
UNiagaraScriptSourceBase* SourceBase = Script->GetLatestSource();
UNiagaraScriptSource* Source = Cast<UNiagaraScriptSource>(SourceBase);
if (!Source || !Source->NodeGraph) return nullptr;
// FindOutputNode is not NIAGARAEDITOR_API; FindEquivalentOutputNode is.
return Source->NodeGraph->FindEquivalentOutputNode(Usage, FGuid());
}
void GatherFunctionCallNodes(UNiagaraNodeOutput* OutputNode, TArray<UNiagaraNodeFunctionCall*>& Out)
{
if (!OutputNode) return;
UEdGraph* Graph = OutputNode->GetGraph();
if (!Graph) return;
for (UEdGraphNode* Node : Graph->Nodes)
{
if (UNiagaraNodeFunctionCall* FC = Cast<UNiagaraNodeFunctionCall>(Node))
{
// Filter to modules attached to this output's usage. GetCalledUsage()
// returns the usage of the inner module script (usually Module).
// Without param-map walking we list all function-calls in the graph
// tied to the same output usage — good enough for list/remove.
Out.Add(FC);
}
}
}
FNiagaraEmitterHandle* FindEmitterHandleMutable(UNiagaraSystem* System, const FString& EmitterName)
{
if (!System) return nullptr;
TArray<FNiagaraEmitterHandle>& Handles = System->GetEmitterHandles();
for (FNiagaraEmitterHandle& H : Handles)
{
if (H.GetName().ToString().Equals(EmitterName, ESearchCase::IgnoreCase))
{
return &H;
}
}
return nullptr;
}
FStackLocation LocateStack(UNiagaraSystem* System, const FString& EmitterName, const FString& GroupName)
{
FStackLocation Loc;
bool bIsSystem = false;
const ENiagaraScriptUsage Usage = ResolveUsage(GroupName, bIsSystem);
if (!System) return Loc;
if (bIsSystem)
{
UNiagaraScript* Script = (Usage == ENiagaraScriptUsage::SystemSpawnScript)
? System->GetSystemSpawnScript()
: System->GetSystemUpdateScript();
Loc.OutputNode = FindOutputNodeFromScript(Script, Usage);
return Loc;
}
FNiagaraEmitterHandle* Handle = FindEmitterHandleMutable(System, EmitterName);
if (!Handle) return Loc;
FVersionedNiagaraEmitter Versioned = Handle->GetInstance();
FVersionedNiagaraEmitterData* Data = Versioned.GetEmitterData();
if (!Data) return Loc;
UNiagaraScript* Script = Data->GetScript(Usage, FGuid());
Loc.OutputNode = FindOutputNodeFromScript(Script, Usage);
return Loc;
}
}
bool UMCPNiagaraLibrary::CreateNiagaraSystem(const FString& PackagePath)
{
if (PackagePath.IsEmpty()) return false;
FString PackageName, AssetName;
if (!PackagePath.Split(TEXT("/"), &PackageName, &AssetName, ESearchCase::IgnoreCase, ESearchDir::FromEnd))
{
return false;
}
IAssetTools& AssetTools = FModuleManager::LoadModuleChecked<FAssetToolsModule>("AssetTools").Get();
UNiagaraSystemFactoryNew* Factory = NewObject<UNiagaraSystemFactoryNew>();
UObject* Created = AssetTools.CreateAsset(AssetName, PackageName, UNiagaraSystem::StaticClass(), Factory);
return Created != nullptr;
}
TArray<FString> UMCPNiagaraLibrary::GetEmitterHandleNames(UNiagaraSystem* System)
{
TArray<FString> Out;
if (!System) return Out;
for (const FNiagaraEmitterHandle& H : System->GetEmitterHandles())
{
Out.Add(H.GetName().ToString());
}
return Out;
}
FString UMCPNiagaraLibrary::AddEmitterToSystem(UNiagaraSystem* System, UNiagaraEmitter* SourceEmitter, const FString& EmitterName)
{
if (!System || !SourceEmitter) return FString();
System->Modify();
// Use the editor-utility path that performs full setup (merge, renderer
// initialization, parameter refresh). The raw UNiagaraSystem::AddEmitterHandle
// skips most of this and leaves the emitter non-functional in preview.
const FGuid HandleId = FNiagaraEditorUtilities::AddEmitterToSystem(
*System, *SourceEmitter, FGuid(), /*bCreateCopy=*/true);
if (!HandleId.IsValid()) return FString();
// Find the handle we just added by id and (optionally) rename it.
for (FNiagaraEmitterHandle& H : System->GetEmitterHandles())
{
if (H.GetId() == HandleId)
{
if (!EmitterName.IsEmpty())
{
H.SetName(FName(*EmitterName), *System);
}
return H.GetName().ToString();
}
}
return FString();
}
bool UMCPNiagaraLibrary::RemoveEmitterFromSystem(UNiagaraSystem* System, const FString& EmitterName)
{
if (!System) return false;
FNiagaraEmitterHandle* Handle = FindEmitterHandleMutable(System, EmitterName);
if (!Handle) return false;
System->Modify();
System->RemoveEmitterHandle(*Handle);
return true;
}
TArray<FString> UMCPNiagaraLibrary::ListModules(UNiagaraSystem* System, const FString& EmitterName, const FString& GroupName)
{
TArray<FString> Out;
const FStackLocation Loc = LocateStack(System, EmitterName, GroupName);
if (!Loc.bValid()) return Out;
TArray<UNiagaraNodeFunctionCall*> ModuleNodes;
GatherFunctionCallNodes(Loc.OutputNode, ModuleNodes);
for (UNiagaraNodeFunctionCall* N : ModuleNodes)
{
if (N) Out.Add(N->GetFunctionName());
}
return Out;
}
FString UMCPNiagaraLibrary::AddModuleToStack(UNiagaraSystem* System, const FString& EmitterName, const FString& GroupName, UNiagaraScript* ModuleScript, int32 Index)
{
if (!ModuleScript) return FString();
const FStackLocation Loc = LocateStack(System, EmitterName, GroupName);
if (!Loc.bValid()) return FString();
System->Modify();
UNiagaraNodeFunctionCall* Added = FNiagaraStackGraphUtilities::AddScriptModuleToStack(
ModuleScript, *Loc.OutputNode, Index, FString(), FGuid());
if (!Added) return FString();
return Added->GetFunctionName();
}
bool UMCPNiagaraLibrary::RemoveModuleFromStack(UNiagaraSystem* System, const FString& EmitterName, const FString& GroupName, const FString& ModuleName)
{
const FStackLocation Loc = LocateStack(System, EmitterName, GroupName);
if (!Loc.bValid()) return false;
TArray<UNiagaraNodeFunctionCall*> ModuleNodes;
GatherFunctionCallNodes(Loc.OutputNode, ModuleNodes);
for (UNiagaraNodeFunctionCall* N : ModuleNodes)
{
if (N && N->GetFunctionName().Equals(ModuleName, ESearchCase::IgnoreCase))
{
System->Modify();
UEdGraph* Graph = N->GetGraph();
if (Graph)
{
Graph->Modify();
N->BreakAllNodeLinks();
Graph->RemoveNode(N);
}
return true;
}
}
return false;
}
FString UMCPNiagaraLibrary::AddRendererToEmitter(UNiagaraSystem* System, const FString& EmitterName, UClass* RendererClass)
{
if (!RendererClass || !RendererClass->IsChildOf(UNiagaraRendererProperties::StaticClass())) return FString();
FNiagaraEmitterHandle* Handle = FindEmitterHandleMutable(System, EmitterName);
if (!Handle) return FString();
FVersionedNiagaraEmitter Versioned = Handle->GetInstance();
UNiagaraEmitter* Emitter = Versioned.Emitter;
if (!Emitter) return FString();
Emitter->Modify();
UNiagaraRendererProperties* Renderer = NewObject<UNiagaraRendererProperties>(Emitter, RendererClass, NAME_None, RF_Transactional);
Emitter->AddRenderer(Renderer, Versioned.Version);
return RendererClass->GetName();
}
int32 UMCPNiagaraLibrary::RemoveRenderersFromEmitter(UNiagaraSystem* System, const FString& EmitterName, UClass* RendererClass)
{
if (!RendererClass) return 0;
FNiagaraEmitterHandle* Handle = FindEmitterHandleMutable(System, EmitterName);
if (!Handle) return 0;
FVersionedNiagaraEmitter Versioned = Handle->GetInstance();
UNiagaraEmitter* Emitter = Versioned.Emitter;
FVersionedNiagaraEmitterData* Data = Versioned.GetEmitterData();
if (!Emitter || !Data) return 0;
Emitter->Modify();
TArray<UNiagaraRendererProperties*> ToRemove;
for (UNiagaraRendererProperties* R : Data->GetRenderers())
{
if (R && R->IsA(RendererClass)) ToRemove.Add(R);
}
for (UNiagaraRendererProperties* R : ToRemove)
{
Emitter->RemoveRenderer(R, Versioned.Version);
}
return ToRemove.Num();
}
bool UMCPNiagaraLibrary::RequestCompile(UNiagaraSystem* System)
{
if (!System) return false;
return System->RequestCompile(true);
}

View File

@ -0,0 +1,948 @@
#include "MCPVoxelGraphLibrary.h"
#include "MCPGraphLibrary.h"
#include "EdGraph/EdGraph.h"
#include "EdGraph/EdGraphNode.h"
#include "EdGraph/EdGraphPin.h"
#include "EdGraphNode_Comment.h"
#include "Subsystems/AssetEditorSubsystem.h"
#include "Editor.h"
#include "Dom/JsonObject.h"
#include "Dom/JsonValue.h"
#include "Serialization/JsonSerializer.h"
#include "Serialization/JsonWriter.h"
// WITH_MCP_VOXEL is set by UEBlueprintMCPEditor.Build.cs: 1 when the Voxel plugin
// is present (auto-detected, or forced via env UE_MCP_WITH_VOXEL), 0 otherwise.
// When 0 the whole Voxel integration compiles down to harmless stubs so the
// plugin builds in any project — with or without Voxel installed.
#ifndef WITH_MCP_VOXEL
#define WITH_MCP_VOXEL 0
#endif
#if WITH_MCP_VOXEL
#include "VoxelGraph.h"
#include "VoxelGraphToolkit.h"
#include "VoxelGraphTracker.h"
#include "VoxelTerminalGraph.h"
#include "VoxelTerminalGraphRuntime.h"
#include "VoxelMinimal.h"
#include "VoxelNode.h"
#include "VoxelFunctionLibrary.h"
#include "Nodes/VoxelNode_UFunction.h"
#include "VoxelMinimal/VoxelInstancedStruct.h"
#include "VoxelMinimal/VoxelObjectHelpers.h"
#include "VoxelParameter.h"
#include "VoxelPinType.h"
// Private headers of the VoxelGraphEditor module (reachable via PrivateIncludePaths in Build.cs).
// We only use inline/virtual members from these, so no cross-module link symbols are required.
#include "Nodes/VoxelGraphNode_Struct.h"
namespace
{
static FString VoxelJsonStringify(const TSharedRef<FJsonObject>& Object)
{
FString Out;
const TSharedRef<TJsonWriter<>> Writer = TJsonWriterFactory<>::Create(&Out);
FJsonSerializer::Serialize(Object, Writer);
return Out;
}
static UVoxelGraph* AsVoxelGraph(UObject* Asset)
{
return Cast<UVoxelGraph>(Asset);
}
static void FillTerminalJson(TSharedRef<FJsonObject> Json, const UVoxelTerminalGraph& TerminalGraph)
{
Json->SetStringField(TEXT("guid"), TerminalGraph.GetGuid().ToString());
Json->SetStringField(TEXT("name"), TerminalGraph.GetDisplayName());
Json->SetBoolField(TEXT("is_main"), TerminalGraph.IsMainTerminalGraph());
Json->SetBoolField(TEXT("is_editor"), TerminalGraph.IsEditorTerminalGraph());
const UEdGraph& EdGraph = TerminalGraph.GetEdGraph();
Json->SetStringField(TEXT("ed_graph"), EdGraph.GetName());
Json->SetNumberField(TEXT("node_count"), EdGraph.Nodes.Num());
}
static UVoxelTerminalGraph* FindTerminalGraph(UVoxelGraph* Graph, const FString& Terminal)
{
if (!Graph)
{
return nullptr;
}
if (Terminal.IsEmpty() ||
Terminal.Equals(TEXT("main"), ESearchCase::IgnoreCase) ||
Terminal.Equals(TEXT("Main"), ESearchCase::IgnoreCase))
{
return &Graph->GetMainTerminalGraph();
}
if (Terminal.Equals(TEXT("editor"), ESearchCase::IgnoreCase))
{
return &Graph->GetEditorTerminalGraph();
}
FGuid Guid;
if (FGuid::Parse(Terminal, Guid))
{
return Graph->FindTerminalGraph(Guid);
}
UVoxelTerminalGraph* Result = nullptr;
Graph->ForeachTerminalGraph_NoInheritance([&](UVoxelTerminalGraph& Candidate)
{
if (!Result &&
(Candidate.GetDisplayName().Equals(Terminal, ESearchCase::IgnoreCase) ||
Candidate.GetEdGraph().GetName().Equals(Terminal, ESearchCase::IgnoreCase)))
{
Result = &Candidate;
}
});
return Result;
}
static TSharedRef<FJsonObject> VoxelNodeToJson(UEdGraphNode* Node)
{
TSharedRef<FJsonObject> Json = MakeShared<FJsonObject>();
Json->SetStringField(TEXT("guid"), Node->NodeGuid.ToString());
Json->SetStringField(TEXT("class"), Node->GetClass()->GetName());
Json->SetNumberField(TEXT("x"), Node->NodePosX);
Json->SetNumberField(TEXT("y"), Node->NodePosY);
Json->SetStringField(TEXT("title"), Node->GetNodeTitle(ENodeTitleType::ListView).ToString());
if (!Node->NodeComment.IsEmpty())
{
Json->SetStringField(TEXT("comment"), Node->NodeComment);
}
if (const UEdGraphNode_Comment* CommentNode = Cast<UEdGraphNode_Comment>(Node))
{
Json->SetNumberField(TEXT("w"), CommentNode->NodeWidth);
Json->SetNumberField(TEXT("h"), CommentNode->NodeHeight);
}
TArray<TSharedPtr<FJsonValue>> Pins;
for (UEdGraphPin* Pin : Node->Pins)
{
if (!Pin)
{
continue;
}
TSharedRef<FJsonObject> PinJson = MakeShared<FJsonObject>();
PinJson->SetStringField(TEXT("name"), Pin->PinName.ToString());
PinJson->SetStringField(TEXT("dir"), Pin->Direction == EGPD_Input ? TEXT("in") : TEXT("out"));
PinJson->SetStringField(TEXT("type"), Pin->PinType.PinCategory.ToString());
if (!Pin->PinType.PinSubCategory.IsNone())
{
PinJson->SetStringField(TEXT("subtype"), Pin->PinType.PinSubCategory.ToString());
}
if (Pin->PinType.PinSubCategoryObject.IsValid())
{
PinJson->SetStringField(TEXT("subobj"), Pin->PinType.PinSubCategoryObject->GetPathName());
}
if (!Pin->DefaultValue.IsEmpty())
{
PinJson->SetStringField(TEXT("default"), Pin->DefaultValue);
}
TArray<TSharedPtr<FJsonValue>> Links;
for (UEdGraphPin* LinkedPin : Pin->LinkedTo)
{
if (!LinkedPin || !LinkedPin->GetOwningNode())
{
continue;
}
TSharedRef<FJsonObject> LinkJson = MakeShared<FJsonObject>();
LinkJson->SetStringField(TEXT("node"), LinkedPin->GetOwningNode()->NodeGuid.ToString());
LinkJson->SetStringField(TEXT("pin"), LinkedPin->PinName.ToString());
Links.Add(MakeShared<FJsonValueObject>(LinkJson));
}
if (Links.Num() > 0)
{
PinJson->SetArrayField(TEXT("links"), Links);
}
Pins.Add(MakeShared<FJsonValueObject>(PinJson));
}
Json->SetArrayField(TEXT("pins"), Pins);
return Json;
}
// Lazily-built catalogue of every spawnable Voxel node. Mirrors FVoxelNodeLibrary's
// construction (which lives in VoxelGraphEditor/Private and is not exported), so we
// rebuild it here from the exported reflection helpers.
static const TArray<TSharedRef<const FVoxelNode>>& GetVoxelNodeCatalogue()
{
static TArray<TSharedRef<const FVoxelNode>> Nodes;
static bool bBuilt = false;
if (bBuilt)
{
return Nodes;
}
bBuilt = true;
// Struct nodes (FVoxelNode subclasses): Add, Multiply, noise, output nodes, etc.
for (UScriptStruct* Struct : GetDerivedStructs<FVoxelNode>())
{
if (!Struct)
{
continue;
}
if (Struct->HasMetaData(TEXT("Abstract")) || Struct->HasMetaData(TEXT("Internal")))
{
continue;
}
Nodes.Add(MakeSharedStruct<FVoxelNode>(Struct));
}
// Function-library nodes (UVoxelFunctionLibrary UFUNCTIONs wrapped as FVoxelNode_UFunction):
// most math / position / curve nodes live here.
for (const TSubclassOf<UVoxelFunctionLibrary>& Class : GetDerivedClasses<UVoxelFunctionLibrary>())
{
if (!Class)
{
continue;
}
for (UFunction* Function : GetClassFunctions(Class))
{
if (!Function || Function->HasMetaData(TEXT("Internal")))
{
continue;
}
Nodes.Add(FVoxelNode_UFunction::Make(Function));
}
}
return Nodes;
}
// The UFunction backing a function-library node, or nullptr for plain struct nodes.
static UFunction* GetNodeFunction(const FVoxelNode& Node)
{
if (Node.GetStruct() &&
Node.GetStruct()->IsChildOf(FVoxelNode_UFunction::StaticStruct()))
{
return static_cast<const FVoxelNode_UFunction&>(Node).GetFunction();
}
return nullptr;
}
// Primary, stable identifier used to spawn a node.
static FString GetNodeId(const FVoxelNode& Node)
{
if (const UFunction* Function = GetNodeFunction(Node))
{
const UClass* Owner = Function->GetOwnerClass();
return FString::Printf(TEXT("%s.%s"),
Owner ? *Owner->GetName() : TEXT("?"),
*Function->GetName());
}
return Node.GetStruct() ? Node.GetStruct()->GetName() : FString();
}
// Returns true if NodeId matches the node by any of its accepted aliases.
static bool NodeMatchesId(const FVoxelNode& Node, const FString& NodeId)
{
if (GetNodeId(Node).Equals(NodeId, ESearchCase::IgnoreCase))
{
return true;
}
if (Node.GetDisplayName().Equals(NodeId, ESearchCase::IgnoreCase))
{
return true;
}
if (const UFunction* Function = GetNodeFunction(Node))
{
if (Function->GetName().Equals(NodeId, ESearchCase::IgnoreCase))
{
return true;
}
const UClass* Owner = Function->GetOwnerClass();
if (Owner &&
FString::Printf(TEXT("%s::%s"), *Owner->GetName(), *Function->GetName()).Equals(NodeId, ESearchCase::IgnoreCase))
{
return true;
}
}
else if (const UScriptStruct* Struct = Node.GetStruct())
{
if (Struct->GetPathName().Equals(NodeId, ESearchCase::IgnoreCase))
{
return true;
}
}
return false;
}
static TSharedPtr<const FVoxelNode> FindCatalogueNode(const FString& NodeId)
{
for (const TSharedRef<const FVoxelNode>& Node : GetVoxelNodeCatalogue())
{
if (NodeMatchesId(*Node, NodeId))
{
return Node;
}
}
return nullptr;
}
// Map a friendly string to a Voxel pin type. Defaults to float for unknown input.
static FVoxelPinType PinTypeFromString(const FString& TypeStr)
{
const FString T = TypeStr.ToLower();
if (T == TEXT("double")) return FVoxelPinType::Make<double>();
if (T == TEXT("int") || T == TEXT("int32")) return FVoxelPinType::Make<int32>();
if (T == TEXT("bool")) return FVoxelPinType::Make<bool>();
if (T == TEXT("vector2d") || T == TEXT("vector2"))return FVoxelPinType::Make<FVector2D>();
if (T == TEXT("vector") || T == TEXT("vector3d")) return FVoxelPinType::Make<FVector>();
if (T == TEXT("color") || T == TEXT("linearcolor"))return FVoxelPinType::Make<FLinearColor>();
if (T == TEXT("name")) return FVoxelPinType::Make<FName>();
return FVoxelPinType::Make<float>();
}
// Reflection-based spawn of a (private, non-exported) Voxel graph node class by its script path,
// setting its FGuid "Guid" UPROPERTY. Only inline/virtual calls -> no cross-module link symbols.
static UEdGraphNode* SpawnGuidNodeReflect(UEdGraph* Graph, const TCHAR* ClassPath, const FGuid& Guid, FVector2D Position)
{
UClass* NodeClass = FindObject<UClass>(nullptr, ClassPath);
if (!Graph || !NodeClass)
{
return nullptr;
}
Graph->Modify();
UEdGraphNode* Node = NewObject<UEdGraphNode>(Graph, NodeClass, NAME_None, RF_Transactional);
if (!Node)
{
return nullptr;
}
if (FStructProperty* GuidProp = FindFProperty<FStructProperty>(NodeClass, TEXT("Guid")))
{
*GuidProp->ContainerPtrToValuePtr<FGuid>(Node) = Guid;
}
Node->NodePosX = static_cast<int32>(Position.X);
Node->NodePosY = static_cast<int32>(Position.Y);
Node->CreateNewGuid();
Graph->AddNode(Node, true, false);
Node->PostPlacedNewNode();
if (Node->Pins.Num() == 0)
{
Node->AllocateDefaultPins();
}
return Node;
}
static UVoxelTerminalGraph* OwningTerminal(UEdGraph* Graph)
{
return Graph ? Graph->GetTypedOuter<UVoxelTerminalGraph>() : nullptr;
}
static FGuid ResolveParameterGuid(UVoxelGraph* Graph, const FString& Ref)
{
if (!Graph)
{
return FGuid();
}
FGuid Guid;
if (FGuid::Parse(Ref, Guid) && Graph->FindParameter(Guid))
{
return Guid;
}
for (const FGuid& Candidate : Graph->GetParameters())
{
if (Graph->FindParameterChecked(Candidate).Name.ToString().Equals(Ref, ESearchCase::IgnoreCase))
{
return Candidate;
}
}
return FGuid();
}
// Resolve a terminal-graph property (kind: input/output/local) by guid or name.
static FGuid ResolveTerminalPropGuid(UVoxelTerminalGraph* Terminal, const FString& Kind, const FString& Ref)
{
if (!Terminal)
{
return FGuid();
}
FGuid Guid;
const bool bIsGuid = FGuid::Parse(Ref, Guid);
if (Kind == TEXT("input"))
{
if (bIsGuid && Terminal->FindInput(Guid)) return Guid;
for (const FGuid& G : Terminal->GetFunctionInputs())
if (Terminal->FindInputChecked(G).Name.ToString().Equals(Ref, ESearchCase::IgnoreCase)) return G;
}
else if (Kind == TEXT("output"))
{
if (bIsGuid && Terminal->FindOutput(Guid)) return Guid;
for (const FGuid& G : Terminal->GetFunctionOutputs())
if (Terminal->FindOutputChecked(G).Name.ToString().Equals(Ref, ESearchCase::IgnoreCase)) return G;
}
else if (Kind == TEXT("local"))
{
if (bIsGuid && Terminal->FindLocalVariable(Guid)) return Guid;
for (const FGuid& G : Terminal->GetLocalVariables())
if (Terminal->FindLocalVariableChecked(G).Name.ToString().Equals(Ref, ESearchCase::IgnoreCase)) return G;
}
return FGuid();
}
static TSharedRef<FJsonObject> JsonError(const FString& Message)
{
TSharedRef<FJsonObject> Root = MakeShared<FJsonObject>();
Root->SetBoolField(TEXT("ok"), false);
Root->SetStringField(TEXT("error"), Message);
return Root;
}
}
bool UMCPVoxelGraphLibrary::IsVoxelGraphAsset(UObject* Asset)
{
return AsVoxelGraph(Asset) != nullptr;
}
FString UMCPVoxelGraphLibrary::ListTerminalGraphsJson(UObject* Asset)
{
TSharedRef<FJsonObject> Root = MakeShared<FJsonObject>();
UVoxelGraph* Graph = AsVoxelGraph(Asset);
if (!Graph)
{
Root->SetBoolField(TEXT("ok"), false);
Root->SetStringField(TEXT("error"), TEXT("asset is not a UVoxelGraph"));
return VoxelJsonStringify(Root);
}
Root->SetBoolField(TEXT("ok"), true);
Root->SetStringField(TEXT("asset"), Graph->GetPathName());
Root->SetStringField(TEXT("class"), Graph->GetClass()->GetPathName());
TArray<TSharedPtr<FJsonValue>> Terminals;
Graph->ForeachTerminalGraph_NoInheritance([&](UVoxelTerminalGraph& TerminalGraph)
{
TSharedRef<FJsonObject> Item = MakeShared<FJsonObject>();
FillTerminalJson(Item, TerminalGraph);
Terminals.Add(MakeShared<FJsonValueObject>(Item));
});
Root->SetArrayField(TEXT("terminals"), Terminals);
Root->SetNumberField(TEXT("terminal_count"), Terminals.Num());
return VoxelJsonStringify(Root);
}
UEdGraph* UMCPVoxelGraphLibrary::FindTerminalEdGraph(UObject* Asset, const FString& Terminal)
{
UVoxelTerminalGraph* TerminalGraph = FindTerminalGraph(AsVoxelGraph(Asset), Terminal);
return TerminalGraph ? &TerminalGraph->GetEdGraph() : nullptr;
}
FString UMCPVoxelGraphLibrary::DumpTerminalGraphJson(UObject* Asset, const FString& Terminal)
{
TSharedRef<FJsonObject> Root = MakeShared<FJsonObject>();
UVoxelGraph* Graph = AsVoxelGraph(Asset);
UVoxelTerminalGraph* TerminalGraph = FindTerminalGraph(Graph, Terminal);
if (!Graph || !TerminalGraph)
{
Root->SetBoolField(TEXT("ok"), false);
Root->SetStringField(TEXT("error"), Graph ? TEXT("terminal graph not found") : TEXT("asset is not a UVoxelGraph"));
return VoxelJsonStringify(Root);
}
Root->SetBoolField(TEXT("ok"), true);
Root->SetStringField(TEXT("asset"), Graph->GetPathName());
TSharedRef<FJsonObject> TerminalJson = MakeShared<FJsonObject>();
FillTerminalJson(TerminalJson, *TerminalGraph);
Root->SetObjectField(TEXT("terminal"), TerminalJson);
TArray<TSharedPtr<FJsonValue>> Nodes;
UEdGraph& EdGraph = TerminalGraph->GetEdGraph();
for (UEdGraphNode* Node : EdGraph.Nodes)
{
if (Node)
{
Nodes.Add(MakeShared<FJsonValueObject>(VoxelNodeToJson(Node)));
}
}
Root->SetArrayField(TEXT("nodes"), Nodes);
return VoxelJsonStringify(Root);
}
FString UMCPVoxelGraphLibrary::GetSelectedNodesJson(UObject* Asset, const FString& Terminal)
{
TSharedRef<FJsonObject> Root = MakeShared<FJsonObject>();
UEdGraph* EdGraph = FindTerminalEdGraph(Asset, Terminal);
if (!EdGraph)
{
Root->SetBoolField(TEXT("ok"), false);
Root->SetStringField(TEXT("error"), TEXT("terminal graph not found"));
return VoxelJsonStringify(Root);
}
const TSharedPtr<FVoxelGraphToolkit> Toolkit = FVoxelGraphToolkit::Get(EdGraph);
if (!Toolkit.IsValid())
{
Root->SetBoolField(TEXT("ok"), false);
Root->SetStringField(TEXT("error"), TEXT("voxel graph editor is not open"));
return VoxelJsonStringify(Root);
}
Root->SetBoolField(TEXT("ok"), true);
Root->SetStringField(TEXT("graph"), EdGraph->GetName());
TArray<TSharedPtr<FJsonValue>> Nodes;
for (UEdGraphNode* Node : Toolkit->GetSelectedNodes())
{
if (Node)
{
Nodes.Add(MakeShared<FJsonValueObject>(VoxelNodeToJson(Node)));
}
}
Root->SetArrayField(TEXT("selected"), Nodes);
Root->SetNumberField(TEXT("selected_count"), Nodes.Num());
return VoxelJsonStringify(Root);
}
FString UMCPVoxelGraphLibrary::SpawnCommentNode(UEdGraph* Graph, FVector2D Position, FVector2D Size, const FString& Comment, FLinearColor Color)
{
if (!Graph)
{
return FString();
}
Graph->Modify();
UEdGraphNode_Comment* Node = NewObject<UEdGraphNode_Comment>(Graph);
Node->CreateNewGuid();
Node->NodePosX = static_cast<int32>(Position.X);
Node->NodePosY = static_cast<int32>(Position.Y);
Node->NodeWidth = FMath::Max(64, static_cast<int32>(Size.X));
Node->NodeHeight = FMath::Max(64, static_cast<int32>(Size.Y));
Node->NodeComment = Comment;
Node->CommentColor = Color;
Node->bCommentBubbleVisible = false;
Graph->AddNode(Node, true, false);
Node->PostPlacedNewNode();
NotifyGraphChanged(Graph, false);
return Node->NodeGuid.ToString();
}
FString UMCPVoxelGraphLibrary::ListNodeTypesJson(const FString& Filter, const int32 Limit)
{
TSharedRef<FJsonObject> Root = MakeShared<FJsonObject>();
Root->SetBoolField(TEXT("ok"), true);
const FString FilterLower = Filter.ToLower();
const int32 EffectiveLimit = Limit > 0 ? Limit : MAX_int32;
TArray<TSharedPtr<FJsonValue>> Items;
int32 Total = 0;
for (const TSharedRef<const FVoxelNode>& Node : GetVoxelNodeCatalogue())
{
const FString Id = GetNodeId(*Node);
const FString DisplayName = Node->GetDisplayName();
const FString Category = Node->GetCategory();
if (!FilterLower.IsEmpty())
{
const bool bMatches =
Id.ToLower().Contains(FilterLower) ||
DisplayName.ToLower().Contains(FilterLower) ||
Category.ToLower().Contains(FilterLower);
if (!bMatches)
{
continue;
}
}
++Total;
if (Items.Num() >= EffectiveLimit)
{
continue;
}
TSharedRef<FJsonObject> Item = MakeShared<FJsonObject>();
Item->SetStringField(TEXT("id"), Id);
Item->SetStringField(TEXT("display_name"), DisplayName);
Item->SetStringField(TEXT("category"), Category);
Item->SetBoolField(TEXT("is_pure"), Node->IsPureNode());
Item->SetStringField(TEXT("kind"), GetNodeFunction(*Node) ? TEXT("function") : TEXT("struct"));
Items.Add(MakeShared<FJsonValueObject>(Item));
}
Root->SetArrayField(TEXT("nodes"), Items);
Root->SetNumberField(TEXT("returned"), Items.Num());
Root->SetNumberField(TEXT("total"), Total);
Root->SetBoolField(TEXT("truncated"), Total > Items.Num());
return VoxelJsonStringify(Root);
}
FString UMCPVoxelGraphLibrary::SpawnStructNode(UEdGraph* Graph, const FString& NodeId, FVector2D Position)
{
if (!Graph)
{
return FString();
}
const TSharedPtr<const FVoxelNode> Chosen = FindCatalogueNode(NodeId);
if (!Chosen)
{
return FString();
}
UClass* NodeClass = FindObject<UClass>(nullptr, TEXT("/Script/VoxelGraphEditor.VoxelGraphNode_Struct"));
if (!NodeClass)
{
return FString();
}
Graph->Modify();
// NewObject with the reflected class avoids referencing the (non-exported) StaticClass()
// symbol of UVoxelGraphNode_Struct; the static_cast is purely compile-time.
UVoxelGraphNode_Struct* Node = static_cast<UVoxelGraphNode_Struct*>(
NewObject<UEdGraphNode>(Graph, NodeClass, NAME_None, RF_Transactional));
if (!Node)
{
return FString();
}
// Mirrors FVoxelGraphSchemaAction_NewStructNode::PerformAction: copy the catalogue
// node into the graph node's instanced Struct, then let it build its pins.
Node->Struct = FVoxelInstancedStruct::Make(*Chosen);
Node->NodePosX = static_cast<int32>(Position.X);
Node->NodePosY = static_cast<int32>(Position.Y);
Node->CreateNewGuid();
Graph->AddNode(Node, true, false);
Node->PostPlacedNewNode();
if (Node->Pins.Num() == 0)
{
Node->AllocateDefaultPins();
}
return Node->NodeGuid.ToString();
}
FString UMCPVoxelGraphLibrary::AddGraphPropertyJson(UObject* Asset, const FString& Terminal, const FString& Kind, const FString& Name, const FString& TypeStr, const FString& Category)
{
UVoxelGraph* Graph = AsVoxelGraph(Asset);
if (!Graph)
{
return VoxelJsonStringify(JsonError(TEXT("asset is not a UVoxelGraph")));
}
const FString KindLower = Kind.ToLower();
const FVoxelPinType Type = PinTypeFromString(TypeStr);
const FName PropName = Name.IsEmpty() ? FName(TEXT("NewProperty")) : FName(*Name);
const FGuid Guid = FGuid::NewGuid();
if (KindLower == TEXT("parameter"))
{
FVoxelParameter Parameter;
Parameter.Name = PropName;
Parameter.Type = Type;
#if WITH_EDITORONLY_DATA
Parameter.Category = Category;
#endif
Parameter.Fixup();
Graph->AddParameter(Guid, Parameter);
Graph->Fixup();
}
else
{
UVoxelTerminalGraph* TerminalGraph = FindTerminalGraph(Graph, Terminal);
if (!TerminalGraph)
{
return VoxelJsonStringify(JsonError(TEXT("terminal graph not found")));
}
if (KindLower == TEXT("input"))
{
FVoxelGraphFunctionInput Input;
Input.Name = PropName;
Input.Type = Type;
#if WITH_EDITORONLY_DATA
Input.Category = Category;
#endif
Input.Fixup();
TerminalGraph->AddFunctionInput(Guid, Input);
}
else if (KindLower == TEXT("output"))
{
FVoxelGraphFunctionOutput Output;
Output.Name = PropName;
Output.Type = Type;
#if WITH_EDITORONLY_DATA
Output.Category = Category;
#endif
TerminalGraph->AddFunctionOutput(Guid, Output);
}
else if (KindLower == TEXT("local"))
{
FVoxelGraphLocalVariable LocalVariable;
LocalVariable.Name = PropName;
LocalVariable.Type = Type;
#if WITH_EDITORONLY_DATA
LocalVariable.Category = Category;
#endif
TerminalGraph->AddLocalVariable(Guid, LocalVariable);
}
else
{
return VoxelJsonStringify(JsonError(FString::Printf(TEXT("unknown kind %s (use parameter|input|output|local)"), *Kind)));
}
}
TSharedRef<FJsonObject> Root = MakeShared<FJsonObject>();
Root->SetBoolField(TEXT("ok"), true);
Root->SetStringField(TEXT("guid"), Guid.ToString());
Root->SetStringField(TEXT("name"), PropName.ToString());
Root->SetStringField(TEXT("type"), Type.ToString());
Root->SetStringField(TEXT("kind"), KindLower);
return VoxelJsonStringify(Root);
}
FString UMCPVoxelGraphLibrary::ListGraphPropertiesJson(UObject* Asset, const FString& Terminal, const FString& Kind)
{
UVoxelGraph* Graph = AsVoxelGraph(Asset);
if (!Graph)
{
return VoxelJsonStringify(JsonError(TEXT("asset is not a UVoxelGraph")));
}
const FString KindLower = Kind.ToLower();
TArray<TSharedPtr<FJsonValue>> Items;
const auto AddItem = [&Items](const FGuid& Guid, const FName& PropName, const FVoxelPinType& Type, const FString& Category)
{
TSharedRef<FJsonObject> Item = MakeShared<FJsonObject>();
Item->SetStringField(TEXT("guid"), Guid.ToString());
Item->SetStringField(TEXT("name"), PropName.ToString());
Item->SetStringField(TEXT("type"), Type.ToString());
Item->SetStringField(TEXT("category"), Category);
Items.Add(MakeShared<FJsonValueObject>(Item));
};
if (KindLower == TEXT("parameter"))
{
for (const FGuid& G : Graph->GetParameters())
{
const FVoxelParameter& P = Graph->FindParameterChecked(G);
#if WITH_EDITORONLY_DATA
AddItem(G, P.Name, P.Type, P.Category);
#else
AddItem(G, P.Name, P.Type, FString());
#endif
}
}
else
{
UVoxelTerminalGraph* TerminalGraph = FindTerminalGraph(Graph, Terminal);
if (!TerminalGraph)
{
return VoxelJsonStringify(JsonError(TEXT("terminal graph not found")));
}
if (KindLower == TEXT("input"))
{
for (const FGuid& G : TerminalGraph->GetFunctionInputs())
{
const FVoxelGraphFunctionInput& P = TerminalGraph->FindInputChecked(G);
#if WITH_EDITORONLY_DATA
AddItem(G, P.Name, P.Type, P.Category);
#else
AddItem(G, P.Name, P.Type, FString());
#endif
}
}
else if (KindLower == TEXT("output"))
{
for (const FGuid& G : TerminalGraph->GetFunctionOutputs())
{
const FVoxelGraphFunctionOutput& P = TerminalGraph->FindOutputChecked(G);
#if WITH_EDITORONLY_DATA
AddItem(G, P.Name, P.Type, P.Category);
#else
AddItem(G, P.Name, P.Type, FString());
#endif
}
}
else if (KindLower == TEXT("local"))
{
for (const FGuid& G : TerminalGraph->GetLocalVariables())
{
const FVoxelGraphLocalVariable& P = TerminalGraph->FindLocalVariableChecked(G);
#if WITH_EDITORONLY_DATA
AddItem(G, P.Name, P.Type, P.Category);
#else
AddItem(G, P.Name, P.Type, FString());
#endif
}
}
else
{
return VoxelJsonStringify(JsonError(FString::Printf(TEXT("unknown kind %s (use parameter|input|output|local)"), *Kind)));
}
}
TSharedRef<FJsonObject> Root = MakeShared<FJsonObject>();
Root->SetBoolField(TEXT("ok"), true);
Root->SetStringField(TEXT("kind"), KindLower);
Root->SetArrayField(TEXT("properties"), Items);
Root->SetNumberField(TEXT("count"), Items.Num());
return VoxelJsonStringify(Root);
}
FString UMCPVoxelGraphLibrary::SpawnPropertyNode(UEdGraph* Graph, const FString& Kind, const FString& Ref, FVector2D Position, bool bDeclaration)
{
if (!Graph)
{
return FString();
}
const FString KindLower = Kind.ToLower();
UVoxelTerminalGraph* Terminal = OwningTerminal(Graph);
FGuid Guid;
const TCHAR* ClassPath = nullptr;
if (KindLower == TEXT("parameter"))
{
UVoxelGraph* OwnerGraph = Terminal ? &Terminal->GetGraph() : nullptr;
Guid = ResolveParameterGuid(OwnerGraph, Ref);
ClassPath = TEXT("/Script/VoxelGraphEditor.VoxelGraphNode_Parameter");
}
else if (KindLower == TEXT("input"))
{
Guid = ResolveTerminalPropGuid(Terminal, KindLower, Ref);
ClassPath = TEXT("/Script/VoxelGraphEditor.VoxelGraphNode_FunctionInput");
}
else if (KindLower == TEXT("output"))
{
Guid = ResolveTerminalPropGuid(Terminal, KindLower, Ref);
ClassPath = TEXT("/Script/VoxelGraphEditor.VoxelGraphNode_FunctionOutput");
}
else if (KindLower == TEXT("local"))
{
Guid = ResolveTerminalPropGuid(Terminal, KindLower, Ref);
ClassPath = bDeclaration
? TEXT("/Script/VoxelGraphEditor.VoxelGraphNode_LocalVariableDeclaration")
: TEXT("/Script/VoxelGraphEditor.VoxelGraphNode_LocalVariableUsage");
}
else
{
return FString();
}
if (!Guid.IsValid())
{
return FString();
}
UEdGraphNode* Node = SpawnGuidNodeReflect(Graph, ClassPath, Guid, Position);
return Node ? Node->NodeGuid.ToString() : FString();
}
FString UMCPVoxelGraphLibrary::SpawnCallFunctionNode(UEdGraph* Graph, const FString& FunctionRef, FVector2D Position)
{
UVoxelTerminalGraph* Terminal = OwningTerminal(Graph);
UVoxelGraph* OwnerGraph = Terminal ? &Terminal->GetGraph() : nullptr;
if (!OwnerGraph)
{
return FString();
}
UVoxelTerminalGraph* Function = FindTerminalGraph(OwnerGraph, FunctionRef);
if (!Function || Function->IsMainTerminalGraph() || Function->IsEditorTerminalGraph())
{
return FString();
}
UEdGraphNode* Node = SpawnGuidNodeReflect(
Graph,
TEXT("/Script/VoxelGraphEditor.VoxelGraphNode_CallMemberFunction"),
Function->GetGuid(),
Position);
return Node ? Node->NodeGuid.ToString() : FString();
}
bool UMCPVoxelGraphLibrary::NotifyGraphChanged(UEdGraph* Graph, const bool bCompile)
{
if (!Graph)
{
return false;
}
UVoxelTerminalGraph* TerminalGraph = Graph->GetTypedOuter<UVoxelTerminalGraph>();
if (!TerminalGraph)
{
Graph->MarkPackageDirty();
return false;
}
TerminalGraph->Modify();
TerminalGraph->GetGraph().Modify();
Graph->Modify();
Graph->MarkPackageDirty();
TerminalGraph->GetGraph().MarkPackageDirty();
if (GVoxelGraphTracker)
{
GVoxelGraphTracker->NotifyEdGraphChanged(*Graph);
GVoxelGraphTracker->NotifyTerminalGraphChanged(TerminalGraph->GetGraph());
}
if (bCompile)
{
TerminalGraph->GetRuntime().EnsureIsCompiled(true);
}
return true;
}
bool UMCPVoxelGraphLibrary::OpenVoxelGraphAsset(UObject* Asset)
{
if (!Asset || !GEditor)
{
return false;
}
UAssetEditorSubsystem* AssetEditorSubsystem = GEditor->GetEditorSubsystem<UAssetEditorSubsystem>();
return AssetEditorSubsystem && AssetEditorSubsystem->OpenEditorForAsset(Asset);
}
#else // !WITH_MCP_VOXEL
// ---------------------------------------------------------------------------
// Voxel plugin not present in this project. Provide stub implementations so the
// module always links. voxel_graph_op will report this error to the caller.
// Rebuild with the Voxel plugin installed (or env UE_MCP_WITH_VOXEL=1) to enable.
// ---------------------------------------------------------------------------
static const TCHAR* MCPVoxelDisabledJson()
{
return TEXT("{\"ok\":false,\"error\":\"Voxel plugin not enabled in this build. Install the Voxel plugin and rebuild (UE_MCP_WITH_VOXEL=1) to use voxel_graph_op.\"}");
}
bool UMCPVoxelGraphLibrary::IsVoxelGraphAsset(UObject*) { return false; }
FString UMCPVoxelGraphLibrary::ListTerminalGraphsJson(UObject*) { return MCPVoxelDisabledJson(); }
UEdGraph* UMCPVoxelGraphLibrary::FindTerminalEdGraph(UObject*, const FString&) { return nullptr; }
FString UMCPVoxelGraphLibrary::DumpTerminalGraphJson(UObject*, const FString&) { return MCPVoxelDisabledJson(); }
FString UMCPVoxelGraphLibrary::GetSelectedNodesJson(UObject*, const FString&) { return MCPVoxelDisabledJson(); }
FString UMCPVoxelGraphLibrary::SpawnCommentNode(UEdGraph*, FVector2D, FVector2D, const FString&, FLinearColor) { return FString(); }
FString UMCPVoxelGraphLibrary::ListNodeTypesJson(const FString&, int32) { return MCPVoxelDisabledJson(); }
FString UMCPVoxelGraphLibrary::SpawnStructNode(UEdGraph*, const FString&, FVector2D) { return FString(); }
FString UMCPVoxelGraphLibrary::AddGraphPropertyJson(UObject*, const FString&, const FString&, const FString&, const FString&, const FString&) { return MCPVoxelDisabledJson(); }
FString UMCPVoxelGraphLibrary::ListGraphPropertiesJson(UObject*, const FString&, const FString&) { return MCPVoxelDisabledJson(); }
FString UMCPVoxelGraphLibrary::SpawnPropertyNode(UEdGraph*, const FString&, const FString&, FVector2D, bool) { return FString(); }
FString UMCPVoxelGraphLibrary::SpawnCallFunctionNode(UEdGraph*, const FString&, FVector2D) { return FString(); }
bool UMCPVoxelGraphLibrary::NotifyGraphChanged(UEdGraph*, bool) { return false; }
bool UMCPVoxelGraphLibrary::OpenVoxelGraphAsset(UObject*) { return false; }
#endif // WITH_MCP_VOXEL

View File

@ -0,0 +1,126 @@
#include "MCPWidgetRenderLibrary.h"
#include "Blueprint/UserWidget.h"
#include "Blueprint/WidgetTree.h"
#include "Editor.h"
#include "Engine/TextureRenderTarget2D.h"
#include "Engine/World.h"
#include "HAL/PlatformFileManager.h"
#include "IImageWrapper.h"
#include "IImageWrapperModule.h"
#include "ImageUtils.h"
#include "Misc/FileHelper.h"
#include "Misc/Paths.h"
#include "Modules/ModuleManager.h"
#include "RenderingThread.h"
#include "Slate/WidgetRenderer.h"
bool UMCPWidgetRenderLibrary::RenderWidgetToPNG(
TSubclassOf<UUserWidget> WidgetClass,
const FString& OutputAbsPath,
const FString& FileNameNoExt,
int32 Width,
int32 Height,
FString& OutFullPath,
FString& OutError)
{
OutFullPath.Reset();
OutError.Reset();
if (!WidgetClass)
{
OutError = TEXT("WidgetClass is null");
return false;
}
UWorld* World = GEditor ? GEditor->GetEditorWorldContext().World() : nullptr;
if (!World)
{
OutError = TEXT("No editor world available");
return false;
}
const int32 W = FMath::Clamp(Width, 16, 8192);
const int32 H = FMath::Clamp(Height, 16, 8192);
UUserWidget* Widget = CreateWidget<UUserWidget>(World, WidgetClass);
if (!Widget)
{
OutError = TEXT("CreateWidget returned null");
return false;
}
// FWidgetRenderer wants a transient render target. Use a non-asset one.
UTextureRenderTarget2D* RT = NewObject<UTextureRenderTarget2D>();
RT->ClearColor = FLinearColor(0.f, 0.f, 0.f, 0.f);
RT->TargetGamma = 1.f;
RT->InitCustomFormat(W, H, PF_B8G8R8A8, /*bForceLinearGamma=*/false);
RT->UpdateResourceImmediate(true);
// Use a software widget renderer so we don't need a live Slate window.
FWidgetRenderer* Renderer = new FWidgetRenderer(/*bUseGammaCorrection=*/true);
if (!Renderer)
{
OutError = TEXT("FWidgetRenderer alloc failed");
return false;
}
Renderer->SetIsPrepassNeeded(true);
const FVector2D DrawSize(W, H);
Renderer->DrawWidget(RT, Widget->TakeWidget(), DrawSize, 0.016f, /*bDeferRenderTargetUpdate=*/false);
// Wait for the GPU work to finish so we can read back pixels.
FlushRenderingCommands();
// Read back pixels.
FTextureRenderTargetResource* RTResource = RT->GameThread_GetRenderTargetResource();
if (!RTResource)
{
BeginCleanup(Renderer);
OutError = TEXT("RT resource null after render");
return false;
}
TArray<FColor> Pixels;
Pixels.SetNumUninitialized(W * H);
FReadSurfaceDataFlags ReadFlags(RCM_UNorm);
ReadFlags.SetLinearToGamma(false);
if (!RTResource->ReadPixels(Pixels, ReadFlags))
{
BeginCleanup(Renderer);
OutError = TEXT("ReadPixels failed");
return false;
}
// Encode PNG via ImageWrapper.
IImageWrapperModule& ImageWrapperModule =
FModuleManager::LoadModuleChecked<IImageWrapperModule>(FName("ImageWrapper"));
TSharedPtr<IImageWrapper> PNG = ImageWrapperModule.CreateImageWrapper(EImageFormat::PNG);
if (!PNG.IsValid() ||
!PNG->SetRaw(Pixels.GetData(), Pixels.Num() * sizeof(FColor), W, H, ERGBFormat::BGRA, 8))
{
BeginCleanup(Renderer);
OutError = TEXT("PNG encode failed");
return false;
}
const TArray64<uint8>& Compressed = PNG->GetCompressed(100);
// Ensure directory.
IPlatformFile& PF = FPlatformFileManager::Get().GetPlatformFile();
if (!PF.DirectoryExists(*OutputAbsPath))
{
PF.CreateDirectoryTree(*OutputAbsPath);
}
const FString FullPath = FPaths::Combine(OutputAbsPath, FileNameNoExt + TEXT(".png"));
if (!FFileHelper::SaveArrayToFile(Compressed, *FullPath))
{
BeginCleanup(Renderer);
OutError = FString::Printf(TEXT("SaveArrayToFile failed: %s"), *FullPath);
return false;
}
BeginCleanup(Renderer);
OutFullPath = FullPath;
return true;
}

View File

@ -0,0 +1,347 @@
#include "UEBlueprintMCPEditor.h"
#include "HttpModule.h"
#include "Interfaces/IHttpResponse.h"
#include "Misc/CoreDelegates.h"
#include "Framework/Notifications/NotificationManager.h"
#include "Widgets/Notifications/SNotificationList.h"
#include "BlueprintEditor.h"
#include "BlueprintEditorContext.h"
#include "EdGraph/EdGraph.h"
#include "EdGraph/EdGraphNode.h"
#include "EdGraphNode_Comment.h"
#include "Framework/Application/IInputProcessor.h"
#include "Framework/Application/SlateApplication.h"
#include "Framework/Commands/UIAction.h"
#include "Kismet2/BlueprintEditorUtils.h"
#include "ScopedTransaction.h"
#include "SGraphPanel.h"
#include "ToolMenu.h"
#include "ToolMenuEntry.h"
#include "ToolMenuSection.h"
#include "ToolMenus.h"
#define LOCTEXT_NAMESPACE "UEBlueprintMCPEditor"
namespace
{
const TCHAR* MCPZoneCommentText = TEXT("MCP Zone");
const TCHAR* LegacyMCPZoneCommentText = TEXT("MCP Draft Zone");
bool IsMCPZoneComment(const UEdGraphNode* Node)
{
const UEdGraphNode_Comment* CommentNode = Cast<UEdGraphNode_Comment>(Node);
return CommentNode
&& (CommentNode->NodeComment.Equals(MCPZoneCommentText, ESearchCase::IgnoreCase)
|| CommentNode->NodeComment.Equals(LegacyMCPZoneCommentText, ESearchCase::IgnoreCase));
}
}
class FMCPZoneInputProcessor : public IInputProcessor
{
public:
explicit FMCPZoneInputProcessor(FUEBlueprintMCPEditorModule& InOwner)
: Owner(InOwner)
{
}
virtual void Tick(const float DeltaTime, FSlateApplication& SlateApp, TSharedRef<ICursor> Cursor) override
{
}
virtual bool HandleMouseButtonDownEvent(FSlateApplication& SlateApp, const FPointerEvent& MouseEvent) override
{
if (!Owner.bMCPZoneModeActive || MouseEvent.GetEffectingButton() != EKeys::LeftMouseButton)
{
return false;
}
TSharedPtr<FBlueprintEditor> Editor = Owner.ActiveMCPZoneEditor.Pin();
if (!Editor.IsValid() || !Editor->GetFocusedGraph())
{
return false;
}
TSharedPtr<SGraphEditor> GraphEditor = Editor->OpenGraphAndBringToFront(Editor->GetFocusedGraph(), false);
SGraphPanel* GraphPanel = GraphEditor.IsValid() ? GraphEditor->GetGraphPanel() : nullptr;
if (!GraphPanel)
{
return false;
}
const FGeometry& Geometry = GraphPanel->GetTickSpaceGeometry();
const FVector2f ScreenPosition = UE::Slate::CastToVector2f(MouseEvent.GetScreenSpacePosition());
if (!Geometry.IsUnderLocation(ScreenPosition))
{
return false;
}
bDragging = true;
StartScreenPosition = ScreenPosition;
return true;
}
virtual bool HandleMouseMoveEvent(FSlateApplication& SlateApp, const FPointerEvent& MouseEvent) override
{
return bDragging && Owner.bMCPZoneModeActive;
}
virtual bool HandleMouseButtonUpEvent(FSlateApplication& SlateApp, const FPointerEvent& MouseEvent) override
{
if (!bDragging || MouseEvent.GetEffectingButton() != EKeys::LeftMouseButton)
{
return false;
}
bDragging = false;
const FVector2f EndScreenPosition = UE::Slate::CastToVector2f(MouseEvent.GetScreenSpacePosition());
Owner.TryCreateMCPZoneFromScreenDrag(StartScreenPosition, EndScreenPosition);
return true;
}
virtual const TCHAR* GetDebugName() const override
{
return TEXT("MCPZoneInputProcessor");
}
private:
FUEBlueprintMCPEditorModule& Owner;
bool bDragging = false;
FVector2f StartScreenPosition = FVector2f::ZeroVector;
};
IMPLEMENT_MODULE(FUEBlueprintMCPEditorModule, UEBlueprintMCPEditor)
void FUEBlueprintMCPEditorModule::StartupModule()
{
UToolMenus::RegisterStartupCallback(
FSimpleMulticastDelegate::FDelegate::CreateRaw(this, &FUEBlueprintMCPEditorModule::RegisterMenus));
FCoreDelegates::OnPostEngineInit.AddRaw(this, &FUEBlueprintMCPEditorModule::NotifyBridgeStatus);
}
void FUEBlueprintMCPEditorModule::NotifyBridgeStatus()
{
const FString Url = TEXT("http://127.0.0.1:30010/remote/info");
TSharedRef<IHttpRequest> Request = FHttpModule::Get().CreateRequest();
Request->SetVerb(TEXT("GET"));
Request->SetURL(Url);
Request->SetTimeout(3.0f);
Request->OnProcessRequestComplete().BindLambda(
[](FHttpRequestPtr Req, FHttpResponsePtr Resp, bool bSucceeded)
{
const bool bOk = bSucceeded && Resp.IsValid() && Resp->GetResponseCode() == 200;
FNotificationInfo Info(bOk
? LOCTEXT("MCPBridgeUp", "UE Blueprint MCP bridge ready (Remote Control :30010)")
: LOCTEXT("MCPBridgeDown", "UE Blueprint MCP bridge NOT reachable on :30010 — check Remote Control plugin"));
Info.ExpireDuration = bOk ? 4.0f : 8.0f;
Info.bUseSuccessFailIcons = true;
Info.bFireAndForget = true;
TSharedPtr<SNotificationItem> Item = FSlateNotificationManager::Get().AddNotification(Info);
if (Item.IsValid())
{
Item->SetCompletionState(bOk ? SNotificationItem::CS_Success : SNotificationItem::CS_Fail);
}
});
Request->ProcessRequest();
}
void FUEBlueprintMCPEditorModule::ShutdownModule()
{
FCoreDelegates::OnPostEngineInit.RemoveAll(this);
if (MCPZoneInputProcessor.IsValid() && FSlateApplication::IsInitialized())
{
FSlateApplication::Get().UnregisterInputPreProcessor(MCPZoneInputProcessor);
MCPZoneInputProcessor.Reset();
}
if (UToolMenus::IsToolMenuUIEnabled())
{
UToolMenus::UnRegisterStartupCallback(this);
UToolMenus::UnregisterOwner(this);
}
}
void FUEBlueprintMCPEditorModule::RegisterMenus()
{
FToolMenuOwnerScoped OwnerScoped(this);
UToolMenu* Toolbar = UToolMenus::Get()->ExtendMenu("AssetEditor.BlueprintEditor.ToolBar");
FToolMenuSection& Section = Toolbar->FindOrAddSection("Settings");
Section.AddDynamicEntry("MCPDraftZoneCommands", FNewToolMenuSectionDelegate::CreateLambda([this](FToolMenuSection& InSection)
{
UBlueprintEditorToolMenuContext* Context = InSection.FindContext<UBlueprintEditorToolMenuContext>();
if (!Context || !Context->BlueprintEditor.IsValid() || !Context->GetBlueprintObj())
{
return;
}
TWeakPtr<FBlueprintEditor> WeakEditor = Context->BlueprintEditor;
FUIAction Action(
FExecuteAction::CreateLambda([this, WeakEditor]()
{
ToggleMCPZoneMode(WeakEditor);
}),
FCanExecuteAction::CreateLambda([WeakEditor]()
{
return WeakEditor.IsValid() && WeakEditor.Pin()->GetFocusedGraph() != nullptr;
}),
FIsActionChecked::CreateLambda([this, WeakEditor]()
{
return IsMCPZoneModeActive(WeakEditor);
}));
FToolMenuEntry Entry = FToolMenuEntry::InitToolBarButton(
FName(TEXT("MCPDraftZone")),
FToolUIActionChoice(Action),
LOCTEXT("MCPDraftZone_Label", "MCP Zone"),
LOCTEXT("MCPDraftZone_Tooltip", "Toggle MCP Zone mode. While active, left-mouse drag in the graph to create a generation zone. Toggle off to remove MCP Zone comments while keeping generated nodes."),
FSlateIcon(),
EUserInterfaceActionType::ToggleButton);
InSection.AddEntry(Entry);
}));
}
void FUEBlueprintMCPEditorModule::ToggleMCPZoneMode(TWeakPtr<FBlueprintEditor> BlueprintEditor)
{
TSharedPtr<FBlueprintEditor> Editor = BlueprintEditor.Pin();
if (!Editor.IsValid())
{
return;
}
if (bMCPZoneModeActive && ActiveMCPZoneEditor.Pin() == Editor)
{
RemoveMCPZones(Editor->GetBlueprintObj());
bMCPZoneModeActive = false;
ActiveMCPZoneEditor.Reset();
if (MCPZoneInputProcessor.IsValid() && FSlateApplication::IsInitialized())
{
FSlateApplication::Get().UnregisterInputPreProcessor(MCPZoneInputProcessor);
MCPZoneInputProcessor.Reset();
}
return;
}
if (bMCPZoneModeActive)
{
if (TSharedPtr<FBlueprintEditor> PreviousEditor = ActiveMCPZoneEditor.Pin())
{
RemoveMCPZones(PreviousEditor->GetBlueprintObj());
}
}
bMCPZoneModeActive = true;
ActiveMCPZoneEditor = Editor;
if (!MCPZoneInputProcessor.IsValid() && FSlateApplication::IsInitialized())
{
MCPZoneInputProcessor = MakeShared<FMCPZoneInputProcessor>(*this);
FSlateApplication::Get().RegisterInputPreProcessor(MCPZoneInputProcessor);
}
}
bool FUEBlueprintMCPEditorModule::IsMCPZoneModeActive(TWeakPtr<FBlueprintEditor> BlueprintEditor) const
{
return bMCPZoneModeActive && ActiveMCPZoneEditor.Pin() == BlueprintEditor.Pin();
}
bool FUEBlueprintMCPEditorModule::TryCreateMCPZoneFromScreenDrag(const FVector2f& StartScreenPosition, const FVector2f& EndScreenPosition)
{
TSharedPtr<FBlueprintEditor> Editor = ActiveMCPZoneEditor.Pin();
if (!Editor.IsValid())
{
return false;
}
UBlueprint* Blueprint = Editor->GetBlueprintObj();
UEdGraph* Graph = Editor->GetFocusedGraph();
if (!Blueprint || !Graph)
{
return false;
}
TSharedPtr<SGraphEditor> GraphEditor = Editor->OpenGraphAndBringToFront(Graph, false);
SGraphPanel* GraphPanel = GraphEditor.IsValid() ? GraphEditor->GetGraphPanel() : nullptr;
if (!GraphPanel)
{
return false;
}
const FGeometry& Geometry = GraphPanel->GetTickSpaceGeometry();
const FVector2f LocalStart = UE::Slate::CastToVector2f(Geometry.AbsoluteToLocal(StartScreenPosition));
const FVector2f LocalEnd = UE::Slate::CastToVector2f(Geometry.AbsoluteToLocal(EndScreenPosition));
const FVector2f GraphStart = UE::Slate::CastToVector2f(GraphPanel->PanelCoordToGraphCoord(LocalStart));
const FVector2f GraphEnd = UE::Slate::CastToVector2f(GraphPanel->PanelCoordToGraphCoord(LocalEnd));
CreateMCPZone(Graph, Blueprint, GraphStart, GraphEnd);
return true;
}
void FUEBlueprintMCPEditorModule::CreateMCPZone(UEdGraph* Graph, UBlueprint* Blueprint, const FVector2f& GraphStart, const FVector2f& GraphEnd) const
{
if (!Blueprint || !Graph)
{
return;
}
const FScopedTransaction Transaction(LOCTEXT("CreateMCPDraftZone", "Create MCP Draft Zone"));
Graph->Modify();
Blueprint->Modify();
constexpr int32 MinWidth = 240;
constexpr int32 MinHeight = 160;
const int32 MinX = FMath::FloorToInt(FMath::Min(GraphStart.X, GraphEnd.X));
const int32 MinY = FMath::FloorToInt(FMath::Min(GraphStart.Y, GraphEnd.Y));
const int32 MaxX = FMath::CeilToInt(FMath::Max(GraphStart.X, GraphEnd.X));
const int32 MaxY = FMath::CeilToInt(FMath::Max(GraphStart.Y, GraphEnd.Y));
UEdGraphNode_Comment* ZoneNode = NewObject<UEdGraphNode_Comment>(Graph);
ZoneNode->CreateNewGuid();
ZoneNode->NodeComment = MCPZoneCommentText;
ZoneNode->bCommentBubbleVisible = false;
ZoneNode->CommentColor = FLinearColor(0.05f, 0.32f, 0.85f, 0.35f);
ZoneNode->NodePosX = MinX;
ZoneNode->NodePosY = MinY;
ZoneNode->NodeWidth = FMath::Max(MinWidth, MaxX - MinX);
ZoneNode->NodeHeight = FMath::Max(MinHeight, MaxY - MinY);
Graph->AddNode(ZoneNode, true, false);
ZoneNode->PostPlacedNewNode();
FBlueprintEditorUtils::MarkBlueprintAsModified(Blueprint);
}
void FUEBlueprintMCPEditorModule::RemoveMCPZones(UBlueprint* Blueprint) const
{
if (!Blueprint)
{
return;
}
const FScopedTransaction Transaction(LOCTEXT("RemoveMCPDraftZones", "Remove MCP Draft Zones"));
Blueprint->Modify();
TArray<UEdGraph*> Graphs;
Blueprint->GetAllGraphs(Graphs);
for (UEdGraph* Graph : Graphs)
{
if (!Graph)
{
continue;
}
Graph->Modify();
TArray<UEdGraphNode*> Nodes = Graph->Nodes;
for (UEdGraphNode* Node : Nodes)
{
if (IsMCPZoneComment(Node))
{
Graph->RemoveNode(Node);
}
}
}
FBlueprintEditorUtils::MarkBlueprintAsModified(Blueprint);
}
#undef LOCTEXT_NAMESPACE

View File

@ -0,0 +1,79 @@
// Headless capture helpers for the UEBlueprintMCP plugin.
//
// Lets external agents SEE editor content WITHOUT opening any editor tab/window:
// * RenderAssetThumbnailToPNG — renders ANY asset (StaticMesh, SkeletalMesh,
// Material, Texture, Blueprint, Niagara, AnimSequence, Sound, ...) using the
// editor's per-class thumbnail renderer. No asset editor needs to be open.
// * CaptureEditorSceneToPNG — renders the current editor world from an
// arbitrary camera via an offscreen SceneCaptureComponent2D. No level
// viewport / PIE required.
//
// Both write a PNG to disk and return its absolute path. Called from Python via
// unreal.MCPCaptureLibrary.
#pragma once
#include "CoreMinimal.h"
#include "Kismet/BlueprintFunctionLibrary.h"
#include "MCPCaptureLibrary.generated.h"
UCLASS()
class UEBLUEPRINTMCPEDITOR_API UMCPCaptureLibrary : public UBlueprintFunctionLibrary
{
GENERATED_BODY()
public:
/**
* Render an asset's thumbnail to a PNG file, headlessly.
* Works for every asset type that has a registered thumbnail renderer, which
* is effectively all of them (meshes, materials, textures, blueprints,
* niagara systems, anim sequences, sounds, data assets, ...). The asset
* editor does NOT need to be open.
*
* @param AssetPath Object path, e.g. "/Game/Meshes/SM_Rock" (no class prefix needed).
* @param OutputAbsPath Absolute directory to write into (created if missing).
* @param FileNameNoExt File name WITHOUT extension. ".png" is appended.
* @param Width Pixels, clamped [16, 2048].
* @param Height Pixels, clamped [16, 2048].
* @param OutFullPath On success, absolute path of the written PNG.
* @param OutError On failure, a short description.
*/
UFUNCTION(BlueprintCallable, Category = "MCP|Capture",
meta = (DisplayName = "Render Asset Thumbnail To PNG"))
static bool RenderAssetThumbnailToPNG(
const FString& AssetPath,
const FString& OutputAbsPath,
const FString& FileNameNoExt,
int32 Width,
int32 Height,
FString& OutFullPath,
FString& OutError
);
/**
* Render the active editor world from an arbitrary camera to a PNG, headlessly,
* using an offscreen SceneCaptureComponent2D. No level viewport is needed.
*
* @param Location World-space camera location.
* @param Rotation World-space camera rotation (pitch/yaw/roll).
* @param FOV Horizontal field of view in degrees (clamped [5, 170]).
* @param OutputAbsPath Absolute directory to write into (created if missing).
* @param FileNameNoExt File name WITHOUT extension. ".png" is appended.
* @param Width Pixels, clamped [16, 4096].
* @param Height Pixels, clamped [16, 4096].
* @param OutFullPath On success, absolute path of the written PNG.
* @param OutError On failure, a short description.
*/
UFUNCTION(BlueprintCallable, Category = "MCP|Capture",
meta = (DisplayName = "Capture Editor Scene To PNG"))
static bool CaptureEditorSceneToPNG(
FVector Location,
FRotator Rotation,
float FOV,
const FString& OutputAbsPath,
const FString& FileNameNoExt,
int32 Width,
int32 Height,
FString& OutFullPath,
FString& OutError
);
};

View File

@ -0,0 +1,430 @@
// Exposes K2 graph mutation to Python.
//
// In Python these are reachable as:
// unreal.MCPGraphLibrary.spawn_call_function_node(bp, graph, owner_class, fn_name, pos)
// unreal.MCPGraphLibrary.connect_pins(graph, from_id, "Then", to_id, "Exec")
// etc.
//
// Every spawn returns the node's NodeGuid as a string. Python keeps a local
// id -> NodeGuid map and passes the guid into connect_pins / set_pin_default.
#pragma once
#include "CoreMinimal.h"
#include "Kismet/BlueprintFunctionLibrary.h"
#include "MCPGraphLibrary.generated.h"
class UBlueprint;
class UWidgetBlueprint;
class UWidget;
class UEdGraph;
class UEdGraphNode;
class UEdGraphPin;
class UScriptStruct;
class UEnum;
UCLASS()
class UEBLUEPRINTMCPEDITOR_API UMCPGraphLibrary : public UBlueprintFunctionLibrary
{
GENERATED_BODY()
public:
// ====================================================================
// Graph lookup
// ====================================================================
UFUNCTION(BlueprintCallable, Category = "MCP|Graph")
static UEdGraph* FindEventGraph(UBlueprint* Blueprint);
UFUNCTION(BlueprintCallable, Category = "MCP|Graph")
static UEdGraph* FindFunctionGraph(UBlueprint* Blueprint, FName FunctionName);
UFUNCTION(BlueprintCallable, Category = "MCP|Graph")
static TArray<FString> ListNodeIds(UEdGraph* Graph);
UFUNCTION(BlueprintCallable, Category = "MCP|Graph")
static TArray<FString> ListPinsOnNode(UEdGraph* Graph, const FString& NodeId);
UFUNCTION(BlueprintCallable, Category = "MCP|Graph")
static int32 ClearGraph(UEdGraph* Graph);
UFUNCTION(BlueprintCallable, Category = "MCP|Graph")
static bool DeleteNode(UEdGraph* Graph, const FString& NodeId);
// ====================================================================
// Spawners — basic
// ====================================================================
UFUNCTION(BlueprintCallable, Category = "MCP|Spawn")
static FString SpawnCallFunctionNode(UBlueprint* Blueprint, UEdGraph* Graph, UClass* FunctionOwnerClass, FName FunctionName, FVector2D Position);
UFUNCTION(BlueprintCallable, Category = "MCP|Spawn")
static FString SpawnEventNode(UBlueprint* Blueprint, UEdGraph* Graph, UClass* OwnerClass, FName EventName, FVector2D Position);
UFUNCTION(BlueprintCallable, Category = "MCP|Spawn")
static FString SpawnCustomEventNode(UBlueprint* Blueprint, UEdGraph* Graph, FName EventName, FVector2D Position);
UFUNCTION(BlueprintCallable, Category = "MCP|Network")
static bool SetCustomEventNetworkFlags(UBlueprint* Blueprint, UEdGraph* Graph, const FString& NodeIdOrEventName, FName Mode, bool bReliable);
UFUNCTION(BlueprintCallable, Category = "MCP|Spawn")
static FString SpawnComponentBoundEvent(UBlueprint* Blueprint, UEdGraph* Graph, FName ComponentName, FName DelegateName, FVector2D Position);
UFUNCTION(BlueprintCallable, Category = "MCP|Spawn")
static FString SpawnBranchNode(UBlueprint* Blueprint, UEdGraph* Graph, FVector2D Position);
UFUNCTION(BlueprintCallable, Category = "MCP|Spawn")
static FString SpawnSequenceNode(UBlueprint* Blueprint, UEdGraph* Graph, FVector2D Position, int32 OutputCount);
UFUNCTION(BlueprintCallable, Category = "MCP|Spawn")
static FString SpawnVariableGetNode(UBlueprint* Blueprint, UEdGraph* Graph, FName VariableName, FVector2D Position);
UFUNCTION(BlueprintCallable, Category = "MCP|Spawn")
static FString SpawnVariableSetNode(UBlueprint* Blueprint, UEdGraph* Graph, FName VariableName, FVector2D Position);
UFUNCTION(BlueprintCallable, Category = "MCP|Spawn")
static FString SpawnSelfNode(UBlueprint* Blueprint, UEdGraph* Graph, FVector2D Position);
UFUNCTION(BlueprintCallable, Category = "MCP|Spawn")
static FString SpawnCastNode(UBlueprint* Blueprint, UEdGraph* Graph, UClass* TargetClass, FVector2D Position);
// ====================================================================
// Spawners — flow / collections
// ====================================================================
UFUNCTION(BlueprintCallable, Category = "MCP|Spawn")
static FString SpawnSwitchOnIntNode(UBlueprint* Blueprint, UEdGraph* Graph, FVector2D Position);
UFUNCTION(BlueprintCallable, Category = "MCP|Spawn")
static FString SpawnSwitchOnStringNode(UBlueprint* Blueprint, UEdGraph* Graph, FVector2D Position);
UFUNCTION(BlueprintCallable, Category = "MCP|Spawn")
static FString SpawnSwitchOnEnumNode(UBlueprint* Blueprint, UEdGraph* Graph, UEnum* EnumType, FVector2D Position);
UFUNCTION(BlueprintCallable, Category = "MCP|Spawn")
static FString SpawnMakeArrayNode(UBlueprint* Blueprint, UEdGraph* Graph, FVector2D Position);
UFUNCTION(BlueprintCallable, Category = "MCP|Spawn")
static FString SpawnMakeStructNode(UBlueprint* Blueprint, UEdGraph* Graph, UScriptStruct* StructType, FVector2D Position);
UFUNCTION(BlueprintCallable, Category = "MCP|Spawn")
static FString SpawnBreakStructNode(UBlueprint* Blueprint, UEdGraph* Graph, UScriptStruct* StructType, FVector2D Position);
UFUNCTION(BlueprintCallable, Category = "MCP|Spawn")
static FString SpawnSetFieldsInStructNode(UBlueprint* Blueprint, UEdGraph* Graph, UScriptStruct* StructType, FVector2D Position);
/** Spawn a UK2Node_MacroInstance bound to one of the StandardMacros (ForLoop,
* ForEachLoop, WhileLoop, DoOnce, Gate, FlipFlop, MultiGate, IsValid, DoN). */
UFUNCTION(BlueprintCallable, Category = "MCP|Spawn")
static FString SpawnStandardMacroNode(UBlueprint* Blueprint, UEdGraph* Graph, FName MacroName, FVector2D Position);
UFUNCTION(BlueprintCallable, Category = "MCP|Spawn")
static FString SpawnSpawnActorFromClassNode(UBlueprint* Blueprint, UEdGraph* Graph, FVector2D Position);
UFUNCTION(BlueprintCallable, Category = "MCP|Spawn")
static FString SpawnFormatTextNode(UBlueprint* Blueprint, UEdGraph* Graph, FVector2D Position);
UFUNCTION(BlueprintCallable, Category = "MCP|Spawn")
static FString SpawnGetSubsystemNode(UBlueprint* Blueprint, UEdGraph* Graph, UClass* SubsystemClass, FVector2D Position);
UFUNCTION(BlueprintCallable, Category = "MCP|Spawn")
static FString SpawnLoadAssetNode(UBlueprint* Blueprint, UEdGraph* Graph, FVector2D Position);
// ====================================================================
// Spawners — Tier 2 (knot/comment/enum literal/class defaults/return/arrays)
// ====================================================================
UFUNCTION(BlueprintCallable, Category = "MCP|Spawn")
static FString SpawnKnotNode(UBlueprint* Blueprint, UEdGraph* Graph, FVector2D Position);
UFUNCTION(BlueprintCallable, Category = "MCP|Spawn")
static FString SpawnCommentNode(UBlueprint* Blueprint, UEdGraph* Graph, FVector2D Position, FVector2D Size, const FString& Comment);
UFUNCTION(BlueprintCallable, Category = "MCP|Spawn")
static FString SpawnEnumLiteralNode(UBlueprint* Blueprint, UEdGraph* Graph, UEnum* EnumType, FVector2D Position);
UFUNCTION(BlueprintCallable, Category = "MCP|Spawn")
static FString SpawnGetClassDefaultsNode(UBlueprint* Blueprint, UEdGraph* Graph, UClass* TargetClass, FVector2D Position);
UFUNCTION(BlueprintCallable, Category = "MCP|Spawn")
static FString SpawnFunctionResultNode(UBlueprint* Blueprint, UEdGraph* Graph, FVector2D Position);
UFUNCTION(BlueprintCallable, Category = "MCP|Spawn")
static FString SpawnGetArrayItemNode(UBlueprint* Blueprint, UEdGraph* Graph, FVector2D Position);
UFUNCTION(BlueprintCallable, Category = "MCP|Spawn")
static FString SpawnPromotableOperatorNode(UBlueprint* Blueprint, UEdGraph* Graph, FName OperatorName, FVector2D Position);
/** Add a new case pin to a SwitchInt/SwitchString/SwitchEnum node. */
UFUNCTION(BlueprintCallable, Category = "MCP|Edit")
static bool AddCasePinToSwitch(UEdGraph* Graph, const FString& NodeId);
/** Set FormatText template AND reconstruct so {arg} pins appear. */
UFUNCTION(BlueprintCallable, Category = "MCP|Edit")
static bool FormatTextSetFormat(UEdGraph* Graph, const FString& NodeId, const FString& Format);
/** Force a node to ReconstructNode() (e.g. after toggling pure/latent). */
UFUNCTION(BlueprintCallable, Category = "MCP|Edit")
static bool ReconstructNode(UEdGraph* Graph, const FString& NodeId);
// ====================================================================
// Delegates
// ====================================================================
/** Bind an existing event to a component's multicast delegate.
* target = "ComponentName.DelegateName". */
UFUNCTION(BlueprintCallable, Category = "MCP|Delegate")
static FString SpawnBindDelegateNode(UBlueprint* Blueprint, UEdGraph* Graph, FName ComponentName, FName DelegateName, FVector2D Position);
UFUNCTION(BlueprintCallable, Category = "MCP|Delegate")
static FString SpawnUnbindDelegateNode(UBlueprint* Blueprint, UEdGraph* Graph, FName ComponentName, FName DelegateName, FVector2D Position);
UFUNCTION(BlueprintCallable, Category = "MCP|Delegate")
static FString SpawnCallDelegateNode(UBlueprint* Blueprint, UEdGraph* Graph, FName DispatcherName, FVector2D Position);
/** AssignDelegate = AddDelegate + CustomEvent — a sugar combo node. */
UFUNCTION(BlueprintCallable, Category = "MCP|Delegate")
static FString SpawnAssignDelegateNode(UBlueprint* Blueprint, UEdGraph* Graph, FName ComponentName, FName DelegateName, FVector2D Position);
/** Add an Event Dispatcher (multicast delegate variable) with no params. */
UFUNCTION(BlueprintCallable, Category = "MCP|Authoring")
static bool AddEventDispatcher(UBlueprint* Blueprint, FName DispatcherName);
// ====================================================================
// Edges & defaults
// ====================================================================
UFUNCTION(BlueprintCallable, Category = "MCP|Edges")
static bool ConnectPins(UEdGraph* Graph, const FString& FromNodeId, FName FromPin, const FString& ToNodeId, FName ToPin);
UFUNCTION(BlueprintCallable, Category = "MCP|Edges")
static bool BreakLink(UEdGraph* Graph, const FString& FromNodeId, FName FromPin, const FString& ToNodeId, FName ToPin);
UFUNCTION(BlueprintCallable, Category = "MCP|Edges")
static bool BreakAllNodeLinks(UEdGraph* Graph, const FString& NodeId);
UFUNCTION(BlueprintCallable, Category = "MCP|Edges")
static bool SetPinDefault(UEdGraph* Graph, const FString& NodeId, FName PinName, const FString& Value);
// ====================================================================
// BP-level authoring
// ====================================================================
UFUNCTION(BlueprintCallable, Category = "MCP|Authoring")
static UEdGraph* AddFunctionGraph(UBlueprint* Blueprint, FName FunctionName);
UFUNCTION(BlueprintCallable, Category = "MCP|Authoring")
static UEdGraph* AddMacroGraph(UBlueprint* Blueprint, FName MacroName);
UFUNCTION(BlueprintCallable, Category = "MCP|Authoring")
static bool AddInterface(UBlueprint* Blueprint, const FString& InterfacePath);
UFUNCTION(BlueprintCallable, Category = "MCP|Authoring")
static bool SetVariableDefaultValue(UBlueprint* Blueprint, FName VariableName, const FString& Value);
UFUNCTION(BlueprintCallable, Category = "MCP|Authoring")
static bool SetVariableMetadata(UBlueprint* Blueprint, FName VariableName, bool bInstanceEditable, bool bExposeOnSpawn, const FString& Category, const FString& Tooltip);
UFUNCTION(BlueprintCallable, Category = "MCP|Authoring")
static bool AddLocalVariable(UBlueprint* Blueprint, FName FunctionName, FName VariableName, const FString& TypeSpec, const FString& DefaultValue);
UFUNCTION(BlueprintCallable, Category = "MCP|Authoring")
static FString AddComponentToBlueprint(UBlueprint* Blueprint, UClass* ComponentClass, FName ComponentName);
UFUNCTION(BlueprintCallable, Category = "MCP|Authoring")
static bool AddMemberVariable(UBlueprint* Blueprint, FName VariableName, const FString& TypeSpec);
UFUNCTION(BlueprintCallable, Category = "MCP|Authoring")
static bool RemoveComponent(UBlueprint* Blueprint, FName ComponentName);
UFUNCTION(BlueprintCallable, Category = "MCP|Authoring")
static bool AttachComponent(UBlueprint* Blueprint, FName ChildName, FName ParentName, FName SocketName);
UFUNCTION(BlueprintCallable, Category = "MCP|Authoring")
static bool SetRootComponent(UBlueprint* Blueprint, FName ComponentName);
UFUNCTION(BlueprintCallable, Category = "MCP|Authoring")
static bool SetComponentProperty(UBlueprint* Blueprint, FName ComponentName, FName PropertyName, const FString& Value);
UFUNCTION(BlueprintCallable, Category = "MCP|Authoring")
static bool SetComponentTransform(UBlueprint* Blueprint, FName ComponentName, FVector Location, FRotator Rotation, FVector Scale);
UFUNCTION(BlueprintCallable, Category = "MCP|Authoring")
static bool ConfigureBoxComponent(UBlueprint* Blueprint, FName ComponentName, FVector Extent, FName CollisionProfile);
UFUNCTION(BlueprintCallable, Category = "MCP|Authoring")
static bool RenameVariable(UBlueprint* Blueprint, FName OldName, FName NewName);
UFUNCTION(BlueprintCallable, Category = "MCP|Authoring")
static bool DeleteVariable(UBlueprint* Blueprint, FName VariableName);
UFUNCTION(BlueprintCallable, Category = "MCP|Authoring")
static bool RenameFunction(UBlueprint* Blueprint, FName OldName, FName NewName);
UFUNCTION(BlueprintCallable, Category = "MCP|Authoring")
static bool RemoveFunctionGraph(UBlueprint* Blueprint, FName FunctionName);
UFUNCTION(BlueprintCallable, Category = "MCP|Authoring")
static bool RemoveMacroGraph(UBlueprint* Blueprint, FName MacroName);
/** Add an input/output parameter to a user function. Direction: "input"/"output". */
UFUNCTION(BlueprintCallable, Category = "MCP|Authoring")
static bool AddFunctionParameter(UBlueprint* Blueprint, FName FunctionName, FName ParamName, const FString& TypeSpec, const FString& Direction);
UFUNCTION(BlueprintCallable, Category = "MCP|Authoring")
static bool RemoveFunctionParameter(UBlueprint* Blueprint, FName FunctionName, FName ParamName);
// ====================================================================
// UMG Designer / WidgetTree
// ====================================================================
UFUNCTION(BlueprintCallable, Category = "MCP|WidgetTree")
static FString DescribeWidgetTreeJson(UWidgetBlueprint* Blueprint);
UFUNCTION(BlueprintCallable, Category = "MCP|WidgetTree")
static FString GetWidgetPropertiesJson(UWidgetBlueprint* Blueprint, FName WidgetName);
UFUNCTION(BlueprintCallable, Category = "MCP|WidgetTree")
static FString GetWidgetSlotPropertiesJson(UWidgetBlueprint* Blueprint, FName WidgetName);
UFUNCTION(BlueprintCallable, Category = "MCP|WidgetTree")
static FString AddWidgetToTree(UWidgetBlueprint* Blueprint, UClass* WidgetClass, FName WidgetName, FName ParentName, int32 Index, bool bIsVariable);
UFUNCTION(BlueprintCallable, Category = "MCP|WidgetTree")
static bool RemoveWidgetFromTree(UWidgetBlueprint* Blueprint, FName WidgetName);
UFUNCTION(BlueprintCallable, Category = "MCP|WidgetTree")
static bool RenameWidgetInTree(UWidgetBlueprint* Blueprint, FName OldName, FName NewName);
UFUNCTION(BlueprintCallable, Category = "MCP|WidgetTree")
static bool MoveWidgetInTree(UWidgetBlueprint* Blueprint, FName WidgetName, FName NewParentName, int32 Index);
UFUNCTION(BlueprintCallable, Category = "MCP|WidgetTree")
static bool SetRootWidget(UWidgetBlueprint* Blueprint, FName WidgetName);
UFUNCTION(BlueprintCallable, Category = "MCP|WidgetTree")
static bool SetWidgetIsVariable(UWidgetBlueprint* Blueprint, FName WidgetName, bool bIsVariable);
UFUNCTION(BlueprintCallable, Category = "MCP|WidgetTree")
static bool SetWidgetProperty(UWidgetBlueprint* Blueprint, FName WidgetName, FName PropertyName, const FString& Value);
UFUNCTION(BlueprintCallable, Category = "MCP|WidgetTree")
static bool SetWidgetSlotProperty(UWidgetBlueprint* Blueprint, FName WidgetName, FName PropertyName, const FString& Value);
UFUNCTION(BlueprintCallable, Category = "MCP|WidgetTree")
static bool SetCanvasSlotLayout(UWidgetBlueprint* Blueprint, FName WidgetName, FVector2D Position, FVector2D Size, FVector2D AnchorsMinimum, FVector2D AnchorsMaximum, FVector2D Alignment, bool bAutoSize, int32 ZOrder);
UFUNCTION(BlueprintCallable, Category = "MCP|WidgetTree")
static bool SetNamedSlotContent(UWidgetBlueprint* Blueprint, FName HostWidgetName, FName SlotName, FName ContentWidgetName);
UFUNCTION(BlueprintCallable, Category = "MCP|WidgetTree")
static bool ClearNamedSlotContent(UWidgetBlueprint* Blueprint, FName HostWidgetName, FName SlotName);
UFUNCTION(BlueprintCallable, Category = "MCP|WidgetTree")
static bool SetDesiredFocusWidget(UWidgetBlueprint* Blueprint, FName WidgetName);
/** Create a UMG WidgetAnimation that keys a single float property (e.g. RenderOpacity)
* on WidgetName. Times (seconds) and Values are parallel arrays — each pair is one
* linear key. The animation is added to the WidgetBlueprint's Animations array; loop
* is controlled at PlayAnimation time (NumLoopsToPlay=0). Returns false on bad input
* or if an animation with AnimName already exists. */
UFUNCTION(BlueprintCallable, Category = "MCP|WidgetTree")
static bool CreateWidgetFloatAnimation(UWidgetBlueprint* Blueprint, FName AnimName, FName WidgetName, FName PropertyName, const TArray<float>& Times, const TArray<float>& Values);
// ====================================================================
// Compile / introspect
// ====================================================================
UFUNCTION(BlueprintCallable, Category = "MCP|Asset")
static bool CompileAndSaveBlueprint(UBlueprint* Blueprint);
UFUNCTION(BlueprintCallable, Category = "MCP|Asset")
static TArray<FString> GetCompileErrors(UBlueprint* Blueprint);
/** Compile + capture FCompilerResultsLog. Returns "SEV|NodeName|Message" per entry. */
UFUNCTION(BlueprintCallable, Category = "MCP|Asset")
static TArray<FString> CompileWithMessages(UBlueprint* Blueprint);
/** Dump entire graph as JSON string round-trippable into apply_graph NodeSpec. */
UFUNCTION(BlueprintCallable, Category = "MCP|Asset")
static FString DumpGraphToJson(UEdGraph* Graph);
// ====================================================================
// Discovery — Pipeline v0.3
// ====================================================================
/** List all graphs on a Blueprint. Returns JSON: {event:[], functions:[], macros:[], delegates:[]}. */
UFUNCTION(BlueprintCallable, Category = "MCP|Discover")
static FString ListGraphsJson(UBlueprint* Blueprint);
/** Full BP overview: parent, interfaces, vars, components (SCS tree), functions, dispatchers. JSON. */
UFUNCTION(BlueprintCallable, Category = "MCP|Discover")
static FString DescribeBlueprintJson(UBlueprint* Blueprint);
/** Currently selected nodes in the open BP editor for `Blueprint`. JSON list of NodeGuid strings + per-node info. */
UFUNCTION(BlueprintCallable, Category = "MCP|Discover")
static FString GetSelectedNodesJson(UBlueprint* Blueprint);
/** All currently-open Blueprint editors (paths). */
UFUNCTION(BlueprintCallable, Category = "MCP|Discover")
static TArray<FString> ListOpenBlueprints();
/** For a UK2Node_CallFunction by guid, return {owner_class, function, source_bp_path?, native?} JSON. */
UFUNCTION(BlueprintCallable, Category = "MCP|Discover")
static FString ResolveFunctionSourceJson(UEdGraph* Graph, const FString& NodeId);
/** All node guids in this BP that reference variable `Name`. JSON list grouped by graph. */
UFUNCTION(BlueprintCallable, Category = "MCP|Discover")
static FString FindVariableReferencesJson(UBlueprint* Blueprint, FName VariableName);
/** All call sites of `FunctionName` (defined on Blueprint or external) within this BP. JSON. */
UFUNCTION(BlueprintCallable, Category = "MCP|Discover")
static FString FindFunctionReferencesJson(UBlueprint* Blueprint, FName FunctionName);
/** AssetRegistry-driven BP search by parent class path. Returns list of BP paths. */
UFUNCTION(BlueprintCallable, Category = "MCP|Discover")
static TArray<FString> FindBlueprintsByParent(const FString& ParentClassPath);
/** Hard-referenced assets of this BP (asset registry). */
UFUNCTION(BlueprintCallable, Category = "MCP|Discover")
static TArray<FString> GetReferencedAssets(const FString& AssetPath);
/** Reverse: assets that reference this BP. */
UFUNCTION(BlueprintCallable, Category = "MCP|Discover")
static TArray<FString> GetReferencersOfAsset(const FString& AssetPath);
// ====================================================================
// Editing — single-node operations
// ====================================================================
/** Move nodes by guid to new positions. positions: array of {guid,x,y} encoded as parallel arrays. */
UFUNCTION(BlueprintCallable, Category = "MCP|Edit")
static int32 MoveNodes(UEdGraph* Graph, const TArray<FString>& NodeIds, const TArray<FVector2D>& Positions);
UFUNCTION(BlueprintCallable, Category = "MCP|Edit")
static bool ResizeCommentNode(UEdGraph* Graph, const FString& NodeId, FVector2D Size);
UFUNCTION(BlueprintCallable, Category = "MCP|Edit")
static bool SetNodeComment(UEdGraph* Graph, const FString& NodeId, const FString& Comment, bool bCommentBubbleVisible);
// ====================================================================
// Navigation — open editors, focus nodes
// ====================================================================
UFUNCTION(BlueprintCallable, Category = "MCP|Navigate")
static bool OpenBlueprintEditor(UBlueprint* Blueprint);
UFUNCTION(BlueprintCallable, Category = "MCP|Navigate")
static bool OpenGraphInEditor(UBlueprint* Blueprint, UEdGraph* Graph);
UFUNCTION(BlueprintCallable, Category = "MCP|Navigate")
static bool JumpToNodeInEditor(UBlueprint* Blueprint, UEdGraph* Graph, const FString& NodeId);
UFUNCTION(BlueprintCallable, Category = "MCP|Introspect")
static TArray<FString> ListFunctionsOnClass(UClass* Class);
UFUNCTION(BlueprintCallable, Category = "MCP|Introspect")
static TArray<FString> ListPropertiesOnClass(UClass* Class);
UFUNCTION(BlueprintCallable, Category = "MCP|Introspect")
static TArray<FString> ListDelegatesOnClass(UClass* Class);
UFUNCTION(BlueprintCallable, Category = "MCP|Introspect")
static FString GetPinType(UClass* OwnerClass, FName FunctionName, FName PinName);
private:
static UEdGraphNode* FindNodeByGuid(UEdGraph* Graph, const FString& NodeId);
static UEdGraphPin* FindPinByName(UEdGraphNode* Node, const FName& PinName);
static class USCS_Node* FindSCSNode(UBlueprint* Blueprint, FName ComponentName);
template <typename TNode>
static TNode* PlaceNode(UEdGraph* Graph, FVector2D Position);
};

View File

@ -0,0 +1,28 @@
#pragma once
#include "CoreMinimal.h"
#include "Kismet/BlueprintFunctionLibrary.h"
#include "MCPMaterialLibrary.generated.h"
class UMaterialExpression;
class UMaterial;
class UMaterialFunction;
UCLASS()
class UEBLUEPRINTMCPEDITOR_API UMCPMaterialLibrary : public UBlueprintFunctionLibrary
{
GENERATED_BODY()
public:
UFUNCTION(BlueprintCallable, Category = "MCP|Material")
static TArray<UMaterialExpression*> GetMaterialExpressions(UObject* MaterialOrFunction);
UFUNCTION(BlueprintCallable, Category = "MCP|Material")
static UMaterialExpression* FindMaterialExpression(UObject* MaterialOrFunction, const FString& NodeId);
UFUNCTION(BlueprintCallable, Category = "MCP|Material")
static FString DumpMaterialGraphToJson(UObject* MaterialOrFunction);
UFUNCTION(BlueprintCallable, Category = "MCP|Material")
static FString ConnectMaterialExpressionsRaw(UObject* MaterialOrFunction, UMaterialExpression* FromExpression, const FString& FromOutput, UMaterialExpression* ToExpression, const FString& ToInput);
};

View File

@ -0,0 +1,69 @@
#pragma once
#include "CoreMinimal.h"
#include "Kismet/BlueprintFunctionLibrary.h"
#include "MCPNiagaraLibrary.generated.h"
class UNiagaraSystem;
class UNiagaraEmitter;
class UNiagaraScript;
class UNiagaraRendererProperties;
/**
* Editor-only helpers wrapping Niagara authoring APIs for the MCP server.
* All methods are no-ops/return defaults outside the editor.
*/
UCLASS()
class UMCPNiagaraLibrary : public UBlueprintFunctionLibrary
{
GENERATED_BODY()
public:
/** Create an empty UNiagaraSystem asset at the given /Game/... path. */
UFUNCTION(BlueprintCallable, Category = "MCP|Niagara")
static bool CreateNiagaraSystem(const FString& PackagePath);
/** List emitter handle names on the system. */
UFUNCTION(BlueprintCallable, Category = "MCP|Niagara")
static TArray<FString> GetEmitterHandleNames(UNiagaraSystem* System);
/** Add an emitter (asset) to the system. Returns the new handle name or empty on failure. */
UFUNCTION(BlueprintCallable, Category = "MCP|Niagara")
static FString AddEmitterToSystem(UNiagaraSystem* System, UNiagaraEmitter* SourceEmitter, const FString& EmitterName);
/** Remove an emitter handle (by handle name) from the system. */
UFUNCTION(BlueprintCallable, Category = "MCP|Niagara")
static bool RemoveEmitterFromSystem(UNiagaraSystem* System, const FString& EmitterName);
/**
* List module nodes (function-call nodes) in a stack group.
* GroupName: EmitterSpawn | EmitterUpdate | ParticleSpawn | ParticleUpdate | SystemSpawn | SystemUpdate
* EmitterName ignored for SystemSpawn/SystemUpdate.
* Returns module names in evaluation order.
*/
UFUNCTION(BlueprintCallable, Category = "MCP|Niagara")
static TArray<FString> ListModules(UNiagaraSystem* System, const FString& EmitterName, const FString& GroupName);
/**
* Insert a module script into the stack group. Index = -1 appends.
* Returns the spawned module's function name (used as ModuleName for subsequent ops), or empty on failure.
*/
UFUNCTION(BlueprintCallable, Category = "MCP|Niagara")
static FString AddModuleToStack(UNiagaraSystem* System, const FString& EmitterName, const FString& GroupName, UNiagaraScript* ModuleScript, int32 Index);
/** Remove a module node from the stack group by name. */
UFUNCTION(BlueprintCallable, Category = "MCP|Niagara")
static bool RemoveModuleFromStack(UNiagaraSystem* System, const FString& EmitterName, const FString& GroupName, const FString& ModuleName);
/** Add a renderer (UNiagaraRendererProperties subclass) to an emitter. Returns the renderer class name or empty. */
UFUNCTION(BlueprintCallable, Category = "MCP|Niagara")
static FString AddRendererToEmitter(UNiagaraSystem* System, const FString& EmitterName, UClass* RendererClass);
/** Remove all renderers of a given class from an emitter. Returns count removed. */
UFUNCTION(BlueprintCallable, Category = "MCP|Niagara")
static int32 RemoveRenderersFromEmitter(UNiagaraSystem* System, const FString& EmitterName, UClass* RendererClass);
/** Recompile the Niagara system (and its emitters). */
UFUNCTION(BlueprintCallable, Category = "MCP|Niagara")
static bool RequestCompile(UNiagaraSystem* System);
};

View File

@ -0,0 +1,69 @@
#pragma once
#include "CoreMinimal.h"
#include "Kismet/BlueprintFunctionLibrary.h"
#include "MCPVoxelGraphLibrary.generated.h"
class UEdGraph;
UCLASS()
class UEBLUEPRINTMCPEDITOR_API UMCPVoxelGraphLibrary : public UBlueprintFunctionLibrary
{
GENERATED_BODY()
public:
UFUNCTION(BlueprintCallable, Category = "MCP|VoxelGraph")
static bool IsVoxelGraphAsset(UObject* Asset);
UFUNCTION(BlueprintCallable, Category = "MCP|VoxelGraph")
static FString ListTerminalGraphsJson(UObject* Asset);
UFUNCTION(BlueprintCallable, Category = "MCP|VoxelGraph")
static UEdGraph* FindTerminalEdGraph(UObject* Asset, const FString& Terminal);
UFUNCTION(BlueprintCallable, Category = "MCP|VoxelGraph")
static FString DumpTerminalGraphJson(UObject* Asset, const FString& Terminal);
UFUNCTION(BlueprintCallable, Category = "MCP|VoxelGraph")
static FString GetSelectedNodesJson(UObject* Asset, const FString& Terminal);
UFUNCTION(BlueprintCallable, Category = "MCP|VoxelGraph")
static FString SpawnCommentNode(UEdGraph* Graph, FVector2D Position, FVector2D Size, const FString& Comment, FLinearColor Color);
// Returns a JSON catalogue of every spawnable Voxel node (struct nodes + function-library nodes).
// Filter is an optional case-insensitive substring matched against id / display name / category.
UFUNCTION(BlueprintCallable, Category = "MCP|VoxelGraph")
static FString ListNodeTypesJson(const FString& Filter, int32 Limit);
// Spawns a functional Voxel node (UVoxelGraphNode_Struct) identified by NodeId
// (struct name, struct path, display name, or "Library.Function" for function nodes).
// Returns the new node GUID as string, or empty on failure.
UFUNCTION(BlueprintCallable, Category = "MCP|VoxelGraph")
static FString SpawnStructNode(UEdGraph* Graph, const FString& NodeId, FVector2D Position);
// Registers a graph property and returns its new GUID as JSON {ok, guid, name, type}.
// Kind: "parameter" (graph parameter), "input"/"output" (function in/out), "local" (local variable).
// TypeStr: float|double|int|bool|vector2d|vector|color|name (defaults to float).
UFUNCTION(BlueprintCallable, Category = "MCP|VoxelGraph")
static FString AddGraphPropertyJson(UObject* Asset, const FString& Terminal, const FString& Kind, const FString& Name, const FString& TypeStr, const FString& Category);
// Lists graph properties of the given Kind as JSON {ok, properties:[{guid,name,type,category}]}.
UFUNCTION(BlueprintCallable, Category = "MCP|VoxelGraph")
static FString ListGraphPropertiesJson(UObject* Asset, const FString& Terminal, const FString& Kind);
// Spawns a usage node for an existing property (Ref = guid or name). bDeclaration only matters
// for Kind "local" (declaration vs usage). Returns the new node GUID, or empty on failure.
UFUNCTION(BlueprintCallable, Category = "MCP|VoxelGraph")
static FString SpawnPropertyNode(UEdGraph* Graph, const FString& Kind, const FString& Ref, FVector2D Position, bool bDeclaration);
// Spawns a "Call Function" node for a function terminal graph (FunctionRef = terminal guid or name).
// Returns the new node GUID, or empty on failure.
UFUNCTION(BlueprintCallable, Category = "MCP|VoxelGraph")
static FString SpawnCallFunctionNode(UEdGraph* Graph, const FString& FunctionRef, FVector2D Position);
UFUNCTION(BlueprintCallable, Category = "MCP|VoxelGraph")
static bool NotifyGraphChanged(UEdGraph* Graph, bool bCompile);
UFUNCTION(BlueprintCallable, Category = "MCP|VoxelGraph")
static bool OpenVoxelGraphAsset(UObject* Asset);
};

View File

@ -0,0 +1,42 @@
// Vision-loop helper for the UEBlueprintMCP plugin.
// Renders a UMG WidgetBlueprint to a PNG file so external agents can SEE
// what they just authored. Called from Python via unreal.MCPWidgetRenderLibrary.
#pragma once
#include "CoreMinimal.h"
#include "Kismet/BlueprintFunctionLibrary.h"
#include "MCPWidgetRenderLibrary.generated.h"
class UUserWidget;
UCLASS()
class UEBLUEPRINTMCPEDITOR_API UMCPWidgetRenderLibrary : public UBlueprintFunctionLibrary
{
GENERATED_BODY()
public:
/**
* Render a UserWidget class to a PNG file on disk.
* Returns true on success and fills OutFullPath with the absolute file path.
*
* @param WidgetClass A UUserWidget subclass (e.g. GeneratedClass of a WidgetBlueprint).
* @param OutputAbsPath Absolute directory path where the PNG should be written.
* The folder is created if missing.
* @param FileNameNoExt File name WITHOUT extension. ".png" is appended automatically.
* @param Width Texture width in pixels (clamped to [16, 8192]).
* @param Height Texture height in pixels (clamped to [16, 8192]).
* @param OutFullPath On success, the absolute path of the written PNG.
* @param OutError On failure, a short description of what went wrong.
*/
UFUNCTION(BlueprintCallable, Category = "MCP|UI",
meta = (DisplayName = "Render Widget To PNG"))
static bool RenderWidgetToPNG(
TSubclassOf<UUserWidget> WidgetClass,
const FString& OutputAbsPath,
const FString& FileNameNoExt,
int32 Width,
int32 Height,
FString& OutFullPath,
FString& OutError
);
};

View File

@ -0,0 +1,31 @@
#pragma once
#include "CoreMinimal.h"
#include "Modules/ModuleManager.h"
class FBlueprintEditor;
class IInputProcessor;
class UBlueprint;
class UEdGraph;
class FUEBlueprintMCPEditorModule : public IModuleInterface
{
public:
virtual void StartupModule() override;
virtual void ShutdownModule() override;
private:
friend class FMCPZoneInputProcessor;
void RegisterMenus();
void NotifyBridgeStatus();
void ToggleMCPZoneMode(TWeakPtr<FBlueprintEditor> BlueprintEditor);
bool IsMCPZoneModeActive(TWeakPtr<FBlueprintEditor> BlueprintEditor) const;
bool TryCreateMCPZoneFromScreenDrag(const FVector2f& StartScreenPosition, const FVector2f& EndScreenPosition);
void CreateMCPZone(UEdGraph* Graph, UBlueprint* Blueprint, const FVector2f& GraphStart, const FVector2f& GraphEnd) const;
void RemoveMCPZones(UBlueprint* Blueprint) const;
bool bMCPZoneModeActive = false;
TWeakPtr<FBlueprintEditor> ActiveMCPZoneEditor;
TSharedPtr<IInputProcessor> MCPZoneInputProcessor;
};

View File

@ -0,0 +1,129 @@
using System;
using System.IO;
using System.Collections.Generic;
using UnrealBuildTool;
public class UEBlueprintMCPEditor : ModuleRules
{
public UEBlueprintMCPEditor(ReadOnlyTargetRules Target) : base(Target)
{
PCHUsage = ModuleRules.PCHUsageMode.UseExplicitOrSharedPCHs;
PublicDependencyModuleNames.AddRange(new string[]
{
"Core",
"CoreUObject",
"Engine",
"UMG",
});
PrivateDependencyModuleNames.AddRange(new string[]
{
"UnrealEd",
"UMGEditor",
"BlueprintGraph",
"KismetCompiler",
"Kismet",
"GraphEditor",
"InputCore",
"Slate",
"SlateCore",
"EditorSubsystem",
"ToolMenus",
"AssetTools",
"Json",
"JsonUtilities",
"AssetRegistry",
"HTTP",
"Niagara",
"NiagaraCore",
"NiagaraEditor",
"ImageWrapper",
"RenderCore",
"RHI",
"MovieScene",
"MovieSceneTracks",
});
// ---- Optional Voxel integration -----------------------------------
// voxel_graph_op needs Voxel's editor modules. Voxel is a heavy third-party
// plugin most projects don't have, so it is wired in ONLY when present. The
// C++ side guards everything behind WITH_MCP_VOXEL and compiles to stubs at 0.
// env UE_MCP_WITH_VOXEL = "1" -> force on (errors if the plugin is missing)
// env UE_MCP_WITH_VOXEL = "0" -> force off
// unset -> auto-detect
string voxelEnv = Environment.GetEnvironmentVariable("UE_MCP_WITH_VOXEL");
string voxelPrivate = (voxelEnv == "0") ? null : FindVoxelGraphEditorPrivate(Target);
bool voxelEnabled = voxelPrivate != null && voxelEnv != "0";
if (voxelEnabled)
{
// Reach a few VoxelGraphEditor private headers (node classes). We only use
// inline/virtual members, so no private link symbols cross the boundary.
PrivateIncludePaths.Add(voxelPrivate);
PrivateDependencyModuleNames.AddRange(new string[]
{
"VoxelCore",
"VoxelCoreEditor",
"VoxelGraph",
"VoxelGraphEditor",
});
PublicDefinitions.Add("WITH_MCP_VOXEL=1");
}
else
{
if (voxelEnv == "1")
{
throw new BuildException(
"UE_MCP_WITH_VOXEL=1 but the Voxel plugin (Source/VoxelGraphEditor/Private) " +
"could not be located. Install the Voxel plugin into this project or the engine, " +
"or unset UE_MCP_WITH_VOXEL.");
}
PublicDefinitions.Add("WITH_MCP_VOXEL=0");
}
}
// Locate <Voxel>/Source/VoxelGraphEditor/Private by scanning the project's and
// engine's plugin folders for Voxel.uplugin. Returns null if Voxel isn't installed.
private string FindVoxelGraphEditorPrivate(ReadOnlyTargetRules Target)
{
var roots = new List<string>();
if (Target.ProjectFile != null)
{
roots.Add(Path.Combine(Target.ProjectFile.Directory.FullName, "Plugins"));
}
// The Plugins folder this plugin itself lives in (covers <Project>/Plugins/Voxel).
roots.Add(Path.GetFullPath(Path.Combine(ModuleDirectory, "..", "..", "..")));
try { roots.Add(Path.Combine(EngineDirectory, "Plugins")); } catch { }
foreach (string root in roots)
{
string hit = FindVoxelUnder(root, 0);
if (hit != null) return hit;
}
return null;
}
// Bounded, fault-tolerant recursive search (GetFiles+AllDirectories aborts wholesale
// on a single inaccessible subdir, so we walk manually and swallow per-dir errors).
private string FindVoxelUnder(string dir, int depth)
{
if (depth > 5 || string.IsNullOrEmpty(dir) || !Directory.Exists(dir)) return null;
try
{
string upl = Path.Combine(dir, "Voxel.uplugin");
if (File.Exists(upl))
{
string priv = Path.Combine(dir, "Source", "VoxelGraphEditor", "Private");
if (Directory.Exists(priv)) return priv;
}
foreach (string sub in Directory.GetDirectories(dir))
{
string hit = FindVoxelUnder(sub, depth + 1);
if (hit != null) return hit;
}
}
catch { }
return null;
}
}

36
UEBlueprintMCP.uplugin Normal file
View File

@ -0,0 +1,36 @@
{
"FileVersion": 3,
"Version": 1,
"VersionName": "0.1.0",
"FriendlyName": "Unreal Engine MCP System",
"Description": "Editor-only C++/Python helpers that expose the UE editor (Blueprint graphs, UMG, materials, Niagara, PCG, assets, build, Insights, ...) as MCP tools. Project- and machine-agnostic: all paths are auto-detected.",
"Category": "Editor",
"CreatedBy": "ExbyteLabs",
"CreatedByURL": "https://gitea.exbytestudios.com/ExbyteLabs/unreal-engine-mcp-system-plugin",
"DocsURL": "https://gitea.exbytestudios.com/ExbyteLabs/unreal-engine-mcp-system-plugin",
"MarketplaceURL": "",
"SupportURL": "https://gitea.exbytestudios.com/ExbyteLabs/unreal-engine-mcp-system-plugin",
"EnabledByDefault": true,
"CanContainContent": false,
"IsBetaVersion": true,
"IsExperimentalVersion": false,
"Installed": false,
"Modules": [
{
"Name": "UEBlueprintMCPEditor",
"Type": "Editor",
"LoadingPhase": "Default"
}
],
"Plugins": [
{
"Name": "Voxel",
"Enabled": true,
"Optional": true
},
{
"Name": "Niagara",
"Enabled": true
}
]
}

273
UI_WORKFLOW.md Normal file
View File

@ -0,0 +1,273 @@
# UI_WORKFLOW — UMG/Slate UI authoring guide for AI agents
> **READ THIS BEFORE EDITING ANY `WBP_*` / UMG / SLATE / UI.**
> If you start a UI task without following this file, you will produce a generic
> placeholder ("prototype look"). This document is the contract that turns
> "ugly UMG demo" into "shippable UI".
## 0. Quick decision flow
```
user asks for UI work
├─ Is there a DA_UITheme asset in /Game/UI/Theme/ ? ── no ─→ STEP A. Build theme first.
│ (one-time setup, ~5 min)
├─ Are there base components in /Game/UI/Components/ ? ── no ─→ STEP B. Build component library.
├─ Did the user give a reference image / mood / brand? ── no ─→ STEP C. Ask for one before coding.
└─ All three present? ─→ STEP D. Compose screen → screenshot → iterate.
```
You **must not** invent palettes, spacing, or component shapes on the fly. If
a token is missing, you ADD it to the theme. If a component is missing, you
CREATE it in the library. The screen WBP only composes.
---
## STEP A — The Theme (one DataAsset to rule them all)
### A.1 Class
Create C++ DataAsset `UProjectUITheme : UPrimaryDataAsset` (use `cpp_scaffold_op`):
```cpp
USTRUCT(BlueprintType) struct FProjectUIPalette {
GENERATED_BODY()
UPROPERTY(EditAnywhere) FLinearColor Primary = FLinearColor::White;
UPROPERTY(EditAnywhere) FLinearColor Accent = FLinearColor(1, 0.5, 0.1, 1);
UPROPERTY(EditAnywhere) FLinearColor Danger = FLinearColor::Red;
UPROPERTY(EditAnywhere) FLinearColor Surface = FLinearColor(0.05, 0.05, 0.05, 1);
UPROPERTY(EditAnywhere) FLinearColor SurfaceAlt = FLinearColor(0.1, 0.1, 0.1, 1);
UPROPERTY(EditAnywhere) FLinearColor Ink = FLinearColor(0.9, 0.9, 0.85, 1);
UPROPERTY(EditAnywhere) FLinearColor Muted = FLinearColor(0.5, 0.5, 0.45, 1);
};
USTRUCT(BlueprintType) struct FProjectUIType {
GENERATED_BODY()
UPROPERTY(EditAnywhere) FSlateFontInfo H1; // 48pt display
UPROPERTY(EditAnywhere) FSlateFontInfo H2; // 32pt headings
UPROPERTY(EditAnywhere) FSlateFontInfo Body; // 16pt text
UPROPERTY(EditAnywhere) FSlateFontInfo Caption;// 12pt small
UPROPERTY(EditAnywhere) FSlateFontInfo Mono; // monospace / terminal text
};
UCLASS()
class PROJECTUI_API UProjectUITheme : public UPrimaryDataAsset {
GENERATED_BODY()
public:
UPROPERTY(EditAnywhere, Category="Theme") FProjectUIPalette Palette;
UPROPERTY(EditAnywhere, Category="Theme") FProjectUIType Type;
// Spacing rhythm — use index, not raw px.
UPROPERTY(EditAnywhere, Category="Theme") TArray<float> Spacing = {4, 8, 12, 16, 24, 32, 48, 64};
UPROPERTY(EditAnywhere, Category="Theme") float CornerRadius = 4.f;
UPROPERTY(EditAnywhere, Category="Theme") float BorderWeight = 1.f;
// Named brushes — referenced by their FName key from WBPs.
UPROPERTY(EditAnywhere, Category="Theme") TMap<FName, FSlateBrush> Brushes;
};
```
### A.2 Instance
Create instance at `/Game/UI/Theme/DA_UITheme` (one per visual mode — fork as
`DA_UITheme_<Mode>` if a project needs several). Example tuning:
| Token | Value | Why |
|---|---|---|
| Palette.Surface | `(0.04, 0.05, 0.05, 1)` | near-black base |
| Palette.SurfaceAlt | `(0.09, 0.1, 0.09, 1)` | row striping |
| Palette.Ink | `(0.87, 0.91, 0.83, 1)` | off-white, never pure |
| Palette.Accent | `(0.95, 0.62, 0.21, 1)` | amber highlight |
| Palette.Danger | `(0.78, 0.10, 0.10, 1)` | muted red, not hex red |
| Palette.Muted | `(0.45, 0.50, 0.45, 1)` | green-grey |
| Type.H1 | font `Roboto Mono Bold`, 48, letter spacing +1 | display |
| Type.Body | font `Inter Regular`, 16 | readable body |
| CornerRadius | 0 | sharp / brutalist |
| BorderWeight | 1.5 | thicker → more readable |
### A.3 Access
Every WBP holds `UPROPERTY(EditDefaultsOnly) UProjectUITheme* Theme;` set in Class
Defaults to `DA_UITheme`. No hardcoded colors anywhere. If you catch yourself
typing `FLinearColor(0.7, 0.1, 0.1, 1)` — stop, add a token to the theme instead.
---
## STEP B — Component Library
Build these once, reuse forever. All under `/Game/UI/Components/`.
### Required base set
| Component | Purpose | Variants |
|---|---|---|
| `WBP_Button` | All clickable | Primary, Ghost, Danger, Icon-only |
| `WBP_Card` | Container w/ header/body/footer | Flat, Raised, Outlined |
| `WBP_HUDPanel` | Framed panel (corner brackets, overlay channel) | Top, Bottom, Floating |
| `WBP_StatBar` | Numeric stat bar (health/energy/progress) | Horizontal, Vertical, Circular |
| `WBP_Label` | Text wrapper that auto-picks Type token | H1, H2, Body, Caption, Mono |
| `WBP_Divider` | 1px line | Solid, Dashed, Glitchy |
| `WBP_Icon` | Wraps `Image` with theme tint | size=spacing token |
| `WBP_ListRow` | One row of a list, themed | Selected/Hovered states wired |
| `WBP_Modal` | Centered overlay with backdrop | Confirm, Form, Info |
| `WBP_Toast` | Bottom-slide notification | Info, Warn, Error |
### Authoring rules
- **No hardcoded sizes** — use `Theme->Spacing[idx]` (e.g. padding = `Spacing[3]` = 16px).
- **Every interactive widget has 4 states wired**: Normal, Hovered, Pressed, Disabled. Hooked from theme brushes, not duplicated.
- **Each component exposes `SetVariant(EVariant)` and `SetState(EState)` BP-callable funcs** — so the screen WBP composes by setting flags, not by hand-tweaking child widgets.
- **Animation:** every state transition has a 80-150ms ease. Define one `WidgetAnimation` per state in `WBP_Button`, copy pattern to others. No animation = feels dead.
### Optional stylized overlays
- `WBP_HUDPanel` can expose a Niagara/Material overlay channel — bind project-specific material instances (e.g. `MI_UI_Scanline`, `MI_UI_Noise`) as defaults if your project has them.
- `WBP_Label` Mono variant can use a chromatic-aberration material instance.
- Background of every HUD piece: prefer a themed `MI_UI_Surface` material over a flat solid color when a stylized look is wanted.
---
## STEP C — Reference parsing protocol
When user supplies a reference image, **do not** start coding. First emit:
```
REFERENCE TOKENS
================
Palette (6 colors HEX, ordered by dominance):
1. Primary surface: #__
2. Surface alt: #__
3. Ink (text): #__
4. Accent: #__
5. Danger/warning: #__
6. Muted/secondary: #__
Typography:
H1 size: __pt weight: __ tracking: __
Body size:__pt weight: __
Family hint: __ (serif/sans/mono/condensed)
Spacing rhythm (3 visible gaps in px): __ __ __
→ suggested Spacing array: [__, __, __, __, __, __, __, __]
Corner radius observed: __px (0 = sharp / brutalist)
Border weight observed: __px
Visual hierarchy (one sentence): __
Mood (3 adjectives): __, __, __
Departures from current DA_UITheme:
- Theme.Palette.__ should become #__ (was #__)
- Theme.Spacing[__] should become __ (was __)
```
Then ask the user: "apply to existing theme or fork as `DA_UITheme_<NewName>`?"
Once approved, use `audio_op set_property` / direct asset edits to mutate the
theme DataAsset. Never edit individual WBPs to match a ref — change the theme,
all WBPs follow.
---
## STEP D — The Vision Loop (most important)
This is what makes AI UI not suck. After ANY structural change, you MUST:
```
1. widget_op apply_tree / set_property / ... (your edit)
2. widget_op compile_save {bp_path}
3. widget_op screenshot {bp_path, size: [1920, 1080]}
4. Read the returned png_path. If you are a vision-capable model:
open the file, look at the result, list 3 specific problems before next edit.
5. Loop until no problems remain.
```
### Hard rule
- **Maximum 2 blind edits** (without screenshot) per session. After that, MUST screenshot. If a user reports "it looks bad", your first action is `widget_op screenshot`, not asking for clarification.
- **Before declaring "done"**, last action MUST be a screenshot, and you MUST describe what you see (not what you intended).
- The screenshot landing path is `<Project>/Saved/MCP/widgets/<bp_name>.png`. Always attach this to your turn output.
### What to look for in the screenshot
- **Alignment grid** — are baselines and gutters consistent? Use a mental 8px grid.
- **Hierarchy** — can you ID primary action in <1s by squinting?
- **Density** is it crammed (Unity-tutorial vibe) or breathing?
- **Color count** is anything outside the theme palette? If yes, that's a bug.
- **Edge cases** empty list, longest possible label (16 chars), worst-case localization (German is +30%).
---
## STEP E — Screen composition
When user asks for a new screen (`WBP_MainMenu`, `WBP_HUD_InGame`, ...):
1. Sketch hierarchy in text first:
```
WBP_MainMenu (RootCanvas)
WBP_HUDPanel "title-bar" (anchored top, span)
WBP_Label H1 "GAME TITLE"
WBP_Card "menu-list" (centered, 480x600)
WBP_Button Primary "Continue"
WBP_Button Ghost "New Game"
WBP_Button Ghost "Settings"
WBP_Button Danger "Quit"
WBP_Label Caption "v0.4.2 • Build 2026-05-28" (bottom-left)
```
2. Confirm with user (one screen = one confirmation step, not per widget).
3. Build with `widget_op apply_tree` (single transaction, single undo).
4. Compile + screenshot.
5. Iterate per Step D loop.
---
## Naming & paths (enforced)
| Asset | Path | Naming |
|---|---|---|
| Theme DataAsset | `/Game/UI/Theme/` | `DA_UITheme_*` |
| Base components | `/Game/UI/Components/` | `WBP_<Name>` |
| Screens | `/Game/UI/Screens/` | `WBP_<Screen>` (no prefix like "Menu_" — flat) |
| Materials | `/Game/UI/Materials/` | `M_UI_*`, `MI_UI_*` |
| Icons | `/Game/UI/Icons/` | `T_UI_Icon_<verb>_<noun>` |
| Frame brushes | `/Game/UI/Frames/` | `T_UI_Frame_*` |
If asked to create UI assets outside these paths, ask why. Probably a mistake.
---
## Things NEVER to do (anti-patterns AI agents fall into)
1. ❌ Hardcoded color `FLinearColor(0.2, 0.2, 0.2)` anywhere. → Use theme.
2. ❌ Hardcoded padding `Padding=8`. → Use `Theme->Spacing[1]`.
3. ❌ One giant CanvasPanel screen with absolute coords for everything. → Use HorizontalBox/VerticalBox/Grid composition.
4. ❌ "Just add a `Border` widget" for a card. → Use `WBP_Card` component.
5. ❌ Per-screen unique button styling. → Add a variant to `WBP_Button`.
6. ❌ Default UE font (Roboto). → Always pick from `Theme->Type`.
7. ❌ Default `Button` widget visible to the user. → Always wrap in `WBP_Button` because state animations live there.
8. ❌ Skip Step D. → If you didn't screenshot, you didn't finish.
9. ❌ Generate icons inline as text "≡" or "✕". → Use `WBP_Icon` with a real Texture2D.
---
## Required first action for any UI session
```
discover_op find_bp_by_parent { parent_class_path: "/Script/ProjectUI.ProjectUITheme" }
```
- 0 hits → STEP A (build theme).
- 1+ hits → fetch the `DA_UITheme` palette via `validation_op get_references` or read directly, cache token names in your working memory.
```
discover_op find_bp_by_parent { parent_class_path: "/Script/UMG.UserWidget" }
```
List existing components. If `/Game/UI/Components/WBP_Button` missing → STEP B.
Then and only then proceed to the user's actual request.
---
## Why this works
Without this guide, AI generates UMG by sampling "what UMG typically looks like
on GitHub" — boring, generic, grey. With this guide:
- **Decisions are constrained** (theme tokens, component variants) → no more "AI inventing aesthetics from scratch every time".
- **Composition replaces design** → LLMs are great at composing; bad at painting.
- **Vision loop closes the feedback gap** → AI sees its output, judges it, fixes it.
- **References parse to data**, not vibes → reproducible, debuggable, diffable.
Result: shippable UI in 1-2 iterations instead of 15.

4
agent-gateway/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
node_modules/
auth.env
integrations.json
.generated-audio/

91
agent-gateway/README.md Normal file
View File

@ -0,0 +1,91 @@
# In-engine agent gateway
A local HTTP/SSE server that runs a Claude agent (via the **Claude Agent SDK**)
wired to this project's `ue-blueprint` MCP server, and serves a chat
UI for the Unreal **WebBrowser** widget. Talk to the agent from *inside* the
editor while it edits Blueprints, spawns nodes, builds C++, screenshots UMG, etc.
```
WBP_AgentChat (WebBrowser) ──HTTP/SSE──▶ server.mjs (this)
public/ chat UI ├─ @anthropic-ai/claude-agent-sdk (the agent)
stream text + tool cards ├─ spawns ../src/server.js (ue-blueprint MCP)
clickable /Game asset chips └─ /api/focus → UE Remote Control (sync Content Browser)
```
## One-time setup
1. **Install deps** (already done once):
```powershell
cd Plugins/UEBlueprintMCP/agent-gateway
npm install
```
2. **Give the agent credentials.** The gateway spawns its own agent process, so
it needs auth that does *not* depend on your interactive Claude Code session.
Pick one:
- **Subscription (recommended)** — generate a long-lived token:
```powershell
claude setup-token
```
Copy the printed token into `agent-gateway/auth.env` (gitignored):
```
CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-...
```
- **API key (pay-as-you-go)** — in `auth.env`:
```
ANTHROPIC_API_KEY=sk-ant-api03-...
```
`GET /api/health` returns `"hasAuth": false` until one is present, and the
chat header shows a "no token" state.
3. **Enable UE plugins**: *Web Browser* (for the widget) and *Remote Control API*
(for live tool access — `WebControl.StartServer`, default port 30010).
## Run
```powershell
npm start # → http://127.0.0.1:8765
```
or double-click **`start-gateway.bat`**.
Open `http://127.0.0.1:8765` in any browser to test the UI without UE.
## Inside Unreal
Open **`/Game/UI/Agent/WBP_AgentChat`**, add it to the viewport (or dock it in an
Editor Utility Widget). It hosts a full-bleed WebBrowser pointed at the gateway
URL. See `../UI_WORKFLOW.md` note: this screen is a deliberate exception to the
UMG-token rule — all visuals live in `public/styles.css` (the `:root` block),
not in `DA_UITheme`, because the surface is HTML inside a browser.
## Config (env vars)
| var | default | meaning |
|-------------------|------------------------|----------------------------------|
| `MCP_AGENT_PORT` | `8765` | gateway port |
| `MCP_AGENT_HOST` | `127.0.0.1` | bind host |
| `MCP_AGENT_MODEL` | `claude-opus-4-8` | agent model |
| `UE_PROJECT_DIR` | repo root (auto) | project cwd handed to the agent |
| `UE_REMOTE_CONTROL_URL` | `http://127.0.0.1:30010` | UE Remote Control (asset focus) |
## Endpoints
- `GET /` — chat UI (static from `public/`)
- `GET /api/health` — `{ ok, model, projectDir, mcp, hasAuth }`
- `POST /api/chat` — `{ message, sessionId? }` → **SSE** stream of:
`init` · `text` · `thinking` · `tool_start` · `tool_end` · `error` · `done`.
`done.sessionId` is replayed by the client to continue the conversation.
- `POST /api/focus` — `{ path: "/Game/..." }` → focuses the asset in UE's
Content Browser (used by clickable asset chips).
## Notes
- The agent runs with `permissionMode: bypassPermissions` (no interactive
terminal exists behind a web UI) and inherits the project `CLAUDE.md` via
`settingSources: ["project"]`, so it follows the same conventions Claude Code
does — including the MCP-first and UI vision-loop rules.
- Conversation state is per `sessionId` (SDK `resume`); the browser keeps it in
memory for the page session.

View File

@ -0,0 +1,8 @@
# Copy this file to auth.env (gitignored) and fill in ONE of the following.
# The gateway loads this file at startup; one KEY=VALUE per line.
# Subscription auth (recommended). Get a token with: claude setup-token
CLAUDE_CODE_OAUTH_TOKEN=
# ...or use a pay-as-you-go API key instead:
# ANTHROPIC_API_KEY=

1465
agent-gateway/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,14 @@
{
"name": "ue-mcp-agent-gateway",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Local HTTP/SSE gateway that runs a Claude agent (Agent SDK) wired to the ue-blueprint MCP, and serves the in-engine chat UI for the UE WebBrowser widget.",
"main": "server.mjs",
"scripts": {
"start": "node server.mjs"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.3.158"
}
}

699
agent-gateway/public/app.js Normal file
View File

@ -0,0 +1,699 @@
// In-engine agent — "Forge" client (EventSource transport for UE CEF).
(() => {
"use strict";
const $ = (id) => document.getElementById(id);
const I = {
brand: '<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M12 2l8 4.5v9L12 20l-8-4.5v-9z"/><path d="M12 11l8-4.5M12 11v9M12 11L4 6.5"/></svg>',
history: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><path d="M3 4v4h4"/><path d="M12 8v4l3 2"/></svg>',
plus: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>',
search: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4-4"/></svg>',
server: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><path d="M7 7.5h.01M7 16.5h.01"/></svg>',
mention: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="4"/><path d="M16 12v1.5a2.5 2.5 0 0 0 5 0V12a9 9 0 1 0-3.5 7.1"/></svg>',
tools: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a4 4 0 0 0-5.4 5.4L3 18v3h3l6.3-6.3a4 4 0 0 0 5.4-5.4l-2.5 2.5-2.4-.6-.6-2.4z"/></svg>',
clip: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5l-8.4 8.4a5 5 0 0 1-7.1-7.1l8.4-8.4a3.3 3.3 0 0 1 4.7 4.7l-8.4 8.4a1.7 1.7 0 0 1-2.4-2.4l7.7-7.7"/></svg>',
send: '<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M3.4 20.6l17.4-8a1 1 0 0 0 0-1.8l-17.4-8A1 1 0 0 0 2 3.7L4.2 11l9.8 1-9.8 1L2 20.3a1 1 0 0 0 1.4 1.1z"/></svg>',
check: '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l5 5L19 6"/></svg>',
x: '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg>',
chev: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>',
asset: '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M12 2l8 4.5v9L12 20l-8-4.5v-9z"/><path d="M12 11l8-4.5M12 11v9M12 11L4 6.5"/></svg>',
chat: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M21 12a8 8 0 0 1-8 8H4l3-3a8 8 0 1 1 14-5z"/></svg>',
spark: '<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2l1.8 6.2L20 10l-6.2 1.8L12 18l-1.8-6.2L4 10l6.2-1.8z"/></svg>',
trash: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16M9 7V5h6v2M6 7l1 13h10l1-13"/></svg>',
gear: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.8 1.8 0 0 0 .36 1.98l.04.04-2.78 2.78-.04-.04A1.8 1.8 0 0 0 15 19.4a1.8 1.8 0 0 0-1 .6l-.06.06H10.1l-.06-.06a1.8 1.8 0 0 0-1-.6 1.8 1.8 0 0 0-1.98.36l-.04.04-2.78-2.78.04-.04A1.8 1.8 0 0 0 4.6 15a1.8 1.8 0 0 0-.6-1l-.06-.06V10.1l.06-.06a1.8 1.8 0 0 0 .6-1 1.8 1.8 0 0 0-.36-1.98l-.04-.04 2.78-2.78.04.04A1.8 1.8 0 0 0 9 4.6a1.8 1.8 0 0 0 1-.6l.06-.06h3.84l.06.06a1.8 1.8 0 0 0 1 .6 1.8 1.8 0 0 0 1.98-.36l.04-.04 2.78 2.78-.04.04A1.8 1.8 0 0 0 19.4 9c.1.36.3.7.6 1l.06.06v3.84l-.06.06c-.3.3-.5.64-.6 1z"/></svg>',
volume: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5L6 9H3v6h3l5 4z"/><path d="M15.5 8.5a5 5 0 0 1 0 7M18 6a8 8 0 0 1 0 12"/></svg>',
download: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12M7 10l5 5 5-5M5 21h14"/></svg>',
play: '<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M7 4.8v14.4a1 1 0 0 0 1.55.83l10.1-7.2a1 1 0 0 0 0-1.66L8.55 3.97A1 1 0 0 0 7 4.8z"/></svg>',
pause: '<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M7 5h4v14H7zM13 5h4v14h-4z"/></svg>',
textfix: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 5h10M4 10h7M4 15h6"/><path d="M14 18l2 2 4-5"/></svg>',
translate: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 4h7M8.5 3v2M4 9h9M11 5c-1 4-3.2 6.5-6 8"/><path d="M8 10c1 1.4 2.2 2.5 4 3.5M14 20l4-9 4 9M15.5 17h5"/></svg>',
};
// ---- state -------------------------------------------------------------
let sessionId = null, busy = false, turn = null;
let model = localStorage.getItem("mcp_model") || "claude-opus-4-8";
let MODELS = [{ id: model, name: model, tag: "" }];
let pending = []; // pending attachments [{dataUrl, media, name}]
let chats = []; // [{id,title,model,sessionId,at,msgs:[...]}]
let activeId = null;
const inputEl = $("input"), sendEl = $("send"), threadEl = $("thread"),
statusDot = $("statusDot"), statusText = $("statusText"), toastEl = $("toast"),
threadWrap = $("threadWrap"), mentionPop = $("mentionPop"), modelPop = $("modelPop");
function mountIcons() {
$("brandMark").innerHTML = I.brand; $("historyBtn").innerHTML = I.history;
$("newChatIcon").innerHTML = I.plus; $("searchIcon").innerHTML = I.search;
$("mcpIcon").innerHTML = I.server; $("mentionIcon").innerHTML = I.mention;
$("toolsIcon").innerHTML = I.tools; $("attachIcon").innerHTML = I.clip;
$("sendIcon").innerHTML = I.send; $("modelGlyph").innerHTML = I.spark; $("modelChev").innerHTML = I.chev;
$("soundIcon").innerHTML = I.volume; $("userChev").innerHTML = I.chev;
$("settingsIcon").innerHTML = I.gear; $("integrationsIcon").innerHTML = I.tools;
$("textFixIcon").innerHTML = I.textfix; $("translateIcon").innerHTML = I.translate;
}
// ---- health ------------------------------------------------------------
function refreshHealth() {
return fetch("/api/health").then((r) => r.json()).then((h) => {
if (Array.isArray(h.models) && h.models.length) MODELS = h.models;
if (!MODELS.find((m) => m.id === model)) model = h.model || MODELS[0].id;
const mm = MODELS.find((m) => m.id === model);
$("modelName").textContent = mm ? mm.name : model;
if (h.ueToolCount) ueToolCount = h.ueToolCount;
setMcp(h.tools || []);
if (h.hasAuth === false) setStatus("warn", "нет токена"); else setStatus("on", "online");
}).catch(() => setStatus("off", "offline"));
}
function setStatus(kind, text) { statusDot.className = "dot " + (kind === "on" ? "on" : kind === "warn" ? "warn" : "off"); statusText.textContent = text; }
let ueToolCount = 0;
function setMcp(tools) {
const ue = (tools || []).filter((t) => t.startsWith("mcp__ue-blueprint__")).length;
if (ue) ueToolCount = ue;
$("toolsCount").textContent = ueToolCount || (tools || []).length || "";
$("mcpCount").textContent = "1/1";
$("mcpList").innerHTML = `<div class="mcp-srv"><span class="dot on"></span><span class="srv-name">ue-blueprint</span><span class="srv-self">SELF-HOST</span><span class="srv-tools">${ueToolCount} tools</span></div>`;
}
// The startup tool-prime can race the MCP subprocess connecting; poll health
// a few times until the ue-blueprint tools show up.
function pollTools(n) { if (ueToolCount || n <= 0) return; setTimeout(() => refreshHealth().then(() => pollTools(n - 1)), 3000); }
function toast(t) { toastEl.textContent = t; toastEl.classList.add("show"); clearTimeout(toast._t); toast._t = setTimeout(() => toastEl.classList.remove("show"), 1800); }
// ---- text rendering ----------------------------------------------------
function esc(s) { return s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c])); }
const ASSET_RE = /(\/Game\/[A-Za-z0-9_\/]+(?:\.[A-Za-z0-9_]+)?)/g;
const MENTION_RE = /@([A-Za-z0-9_\/\.]+)/g;
function chipsInSegment(seg) {
seg = seg.replace(MENTION_RE, (m, name) => {
const isAsset = name.startsWith("/Game/");
const leaf = name.split("/").pop().split(".")[0];
return `<span class="mention" data-asset="${isAsset ? name : ""}" data-copy="${esc(name)}">${I.mention}${esc(leaf)}</span>`;
});
seg = seg.replace(ASSET_RE, (m) => {
const path = m.replace(/\.$/, ""); const leaf = path.split("/").pop();
return `<span class="objlink" data-asset="${path}" title="${esc(path)}"><span class="ol-ico">${I.asset}</span>${esc(leaf)}</span>`;
});
return seg;
}
function renderMarkdown(text) {
const parts = text.split(/```/); let html = "";
for (let i = 0; i < parts.length; i++) {
if (i % 2 === 1) { html += `<pre><code>${esc(parts[i].replace(/^[a-zA-Z0-9]*\n/, ""))}</code></pre>`; }
else {
let seg = esc(parts[i]);
seg = seg.replace(/`([^`]+)`/g, (_, c) => `<code>${c}</code>`);
seg = seg.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
seg = seg.split(/(<code>.*?<\/code>)/g).map((s) => (s.startsWith("<code>") ? s : chipsInSegment(s))).join("");
html += seg.replace(/\n/g, "<br>");
}
}
return html;
}
function nowStr() { const d = new Date(); return d.getHours() + ":" + String(d.getMinutes()).padStart(2, "0"); }
// throttled scroll
let scrollQueued = false;
function scrollDown() { if (scrollQueued) return; scrollQueued = true; requestAnimationFrame(() => { scrollQueued = false; threadWrap.scrollTop = threadWrap.scrollHeight; }); }
// ---- message builders --------------------------------------------------
function imagesHtml(images) {
if (!images || !images.length) return "";
return `<div class="msg-images">${images.map((d) => `<img src="${d}" />`).join("")}</div>`;
}
function addUserMessage(text, images) {
const el = document.createElement("div"); el.className = "msg user-msg";
el.innerHTML = `<div class="msg-avatar user">EX</div><div class="msg-body"><div class="msg-name">Вы <span class="ts">${nowStr()}</span></div>` +
imagesHtml(images) + `<div class="msg-text">${renderMarkdown(text)}</div></div>`;
threadEl.appendChild(el); scrollDown();
}
function addAgentShell() {
const el = document.createElement("div"); el.className = "msg";
el.innerHTML = `<div class="msg-avatar ai">${I.brand}</div><div class="msg-body"><div class="msg-name">Forge <span class="ts">${($("modelName").textContent)}</span></div></div>`;
threadEl.appendChild(el);
return { el, body: el.querySelector(".msg-body") };
}
function audioSetHtml(message) {
const sounds = message.sounds || [];
return `<div class="audio-prompt">ElevenLabs variations for: <strong>${esc(message.prompt || "")}</strong></div><div class="audio-set">` +
sounds.map((s, i) => `<div class="audio-card" data-audio-card><div class="audio-card-head"><span class="audio-badge">${I.volume}</span><span class="audio-name">${esc(s.name || `variation_${i + 1}`)}</span><span class="audio-meta">${Math.max(1, Math.round((s.bytes || 0) / 1024))} KB</span><button class="import-btn ${s.imported ? "done" : ""}" data-import-audio="${esc(s.id)}">${s.imported ? "Imported" : `${I.download} Import`}</button></div><div class="forge-player"><button class="player-play" data-player-play title="Play">${I.play}</button><span class="player-time" data-player-current>0:00</span><input class="player-progress" data-player-progress type="range" min="0" max="1000" value="0" step="1" aria-label="Audio progress" /><span class="player-time" data-player-duration>0:00</span><span class="player-volume-icon">${I.volume}</span><input class="player-volume" data-player-volume type="range" min="0" max="100" value="85" step="1" aria-label="Volume" /><audio data-player-audio preload="metadata" src="${esc(s.audioUrl)}"></audio></div></div>`).join("") +
`</div>`;
}
function renderStoredAudio(message) {
const sh = addAgentShell();
const el = document.createElement("div"); el.className = "generated-audio"; el.innerHTML = audioSetHtml(message);
sh.body.appendChild(el);
}
function addAudioMessage(prompt, sounds) {
const message = { role: "audio", prompt, sounds };
renderStoredAudio(message);
pushMsg(message); saveChats(); scrollDown();
}
function fmtTime(seconds) {
if (!Number.isFinite(seconds) || seconds < 0) return "0:00";
const mins = Math.floor(seconds / 60), secs = Math.floor(seconds % 60);
return `${mins}:${String(secs).padStart(2, "0")}`;
}
function syncPlayer(card) {
const audio = card.querySelector("[data-player-audio]");
const progress = card.querySelector("[data-player-progress]");
card.querySelector("[data-player-current]").textContent = fmtTime(audio.currentTime);
card.querySelector("[data-player-duration]").textContent = fmtTime(audio.duration);
progress.value = audio.duration ? String(Math.round((audio.currentTime / audio.duration) * 1000)) : "0";
const play = card.querySelector("[data-player-play]");
play.innerHTML = audio.paused ? I.play : I.pause;
play.title = audio.paused ? "Play" : "Pause";
}
function stopOtherPlayers(except) {
threadEl.querySelectorAll("[data-player-audio]").forEach((audio) => {
if (audio !== except && !audio.paused) { audio.pause(); syncPlayer(audio.closest("[data-audio-card]")); }
});
}
const VERB = {
ping_ue: "Пинг UE", read_graph: "Чтение графа", apply_graph: "Правка графа", apply_batch: "Правка графа",
compile: "Компиляция", introspect: "Интроспекция", discover_op: "Поиск в проекте", asset_op: "Ассет",
widget_op: "Виджет", level_op: "Уровень", material_op: "Материал", niagara_op: "Niagara", console_op: "Консоль",
live_coding_op: "Live Coding", build_op: "Сборка", validation_op: "Валидация", read_python_log: "Лог Python",
get_active_blueprint: "Активный BP", selection_op: "Выделение", voxel_graph_op: "Voxel", animation_op: "Анимация",
audio_op: "Аудио", ai_op: "AI", input_op: "Input", mesh_op: "Меш", render_op: "Рендер", network_op: "Сеть",
preset_op: "Пресет", cull_op: "Culling", Read: "Чтение", Edit: "Правка", Write: "Запись", Bash: "Bash",
Grep: "Поиск", Glob: "Поиск файлов", TodoWrite: "План", Task: "Подзадача", WebFetch: "Веб", WebSearch: "Веб-поиск",
};
const toolBase = (n) => n.replace(/^mcp__ue-blueprint__/, "").replace(/^mcp__[^_]+__/, "");
function toolLabel(n) { if (n.startsWith("mcp__ue-blueprint__")) return "ue." + toolBase(n); if (n.startsWith("mcp__")) return n.replace(/^mcp__([^_]+)__/, "$1."); return n; }
function targetOf(input) {
if (!input || typeof input !== "object") return null;
let t = input.bp_path || input.path || input.widget || input.class || input.bp || input.file_path || input.class_path;
if (!t && Array.isArray(input.ops) && input.ops[0]) t = input.ops[0].bp_path;
return typeof t === "string" ? t : null;
}
function chipFor(tgt, base) {
if (!tgt) return "";
if (tgt.startsWith("/Game/")) return `<span class="objlink" data-asset="${esc(tgt)}" title="${esc(tgt)}"><span class="ol-ico">${I.asset}</span>${esc(tgt.split("/").pop())}</span>`;
if (/^[A-Za-z0-9_]+$/.test(tgt) && tgt !== base) return `<span class="objlink"><span class="ol-ico">${I.asset}</span>${esc(tgt)}</span>`;
return "";
}
function plural(n) { return n % 10 === 1 && n % 100 !== 11 ? "шаг" : (n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20) ? "шага" : "шагов"); }
function ensureConsole() {
if (turn.console) return;
const c = document.createElement("div"); c.className = "console";
c.innerHTML = `<div class="console-head"><div class="console-spin"></div><div class="console-title">Работаю… <span class="console-meta"></span></div><span class="console-chev">${I.chev}</span></div><div class="console-body"><div class="console-inner"></div></div>`;
turn.body.appendChild(c); turn.console = c; turn.stepsEl = c.querySelector(".console-inner");
c.querySelector(".console-head").addEventListener("click", () => c.classList.toggle("collapsed"));
scrollDown();
}
function addStep(id, name, input) {
ensureConsole(); turn.nStep++;
const base = toolBase(name), verb = VERB[base] || VERB[name] || "Инструмент", tgt = targetOf(input);
const step = document.createElement("div"); step.className = "step run";
step.innerHTML = `<div class="step-node">${turn.nStep}</div><div class="step-main"><div class="step-row"><div class="step-verb"><span class="v">${esc(verb)}</span>${chipFor(tgt, base)}<span class="step-tool">${esc(toolLabel(name))}</span></div><span class="step-toggle" style="display:none">details</span><span class="step-dur"></span></div><div class="collapsible"><div><div class="tool-detail" style="display:none"></div></div></div></div>`;
turn.stepsEl.appendChild(step);
turn.steps.set(id, { el: step, t0: performance.now(), verb, tgt, tool: toolLabel(name) });
if (turn.console) turn.console.querySelector(".console-meta").textContent = "· " + turn.nStep + " " + plural(turn.nStep);
scrollDown();
}
function finishStep(id, isError, content) {
const s = turn && turn.steps.get(id); if (!s) return;
const dur = ((performance.now() - s.t0) / 1000).toFixed(1) + "s";
s.el.classList.remove("run"); s.el.classList.add(isError ? "err" : "done");
s.el.querySelector(".step-node").innerHTML = isError ? I.x : I.check;
s.el.querySelector(".step-dur").textContent = dur;
s.dur = dur; s.ok = !isError;
const text = (content || "").toString().trim();
if (text) {
const toggle = s.el.querySelector(".step-toggle"), detail = s.el.querySelector(".tool-detail"), wrap = s.el.querySelector(".collapsible");
detail.style.display = ""; detail.innerHTML = `<div class="td-head">результат</div><div class="td-body">${esc(text.length > 6000 ? text.slice(0, 6000) + "\n…" : text)}</div>`;
toggle.style.display = "";
toggle.addEventListener("click", () => { wrap.classList.toggle("open"); toggle.textContent = wrap.classList.contains("open") ? "hide" : "details"; });
if (isError) { wrap.classList.add("open"); toggle.textContent = "hide"; }
}
scrollDown();
}
function ensureText() {
if (turn.textEl) return;
turn.textEl = document.createElement("div"); turn.textEl.className = "msg-text";
turn.liveEl = document.createElement("span"); turn.caretEl = document.createElement("span"); turn.caretEl.className = "streaming-caret";
turn.textEl.appendChild(turn.liveEl); turn.textEl.appendChild(turn.caretEl);
turn.body.appendChild(turn.textEl);
}
function appendText(delta) { ensureText(); turn.raw += delta; turn.liveEl.appendChild(document.createTextNode(delta)); scrollDown(); }
function appendThinking(delta) { if (!turn.thinkEl) { turn.thinkEl = document.createElement("div"); turn.thinkEl.className = "think"; turn.body.insertBefore(turn.thinkEl, turn.console || null); } turn.thinkEl.textContent += delta; scrollDown(); }
function finalizeTurn(durMs) {
if (!turn) return;
if (turn.console) {
turn.console.classList.add("collapsed");
const head = turn.console.querySelector(".console-head");
const spin = head.querySelector(".console-spin"); if (spin) spin.outerHTML = `<span class="console-check">${I.check}</span>`;
const secs = durMs ? (durMs / 1000).toFixed(0) : Math.round((performance.now() - turn.t0) / 1000);
head.querySelector(".console-title").childNodes[0].nodeValue = `Готово за ${secs}s `;
}
if (turn.textEl) {
if (turn.caretEl) turn.caretEl.remove();
if (turn.raw.trim()) turn.textEl.innerHTML = renderMarkdown(turn.raw); else turn.textEl.remove();
}
if (!turn.console && !turn.textEl) turn.el.remove();
// persist assistant message
const steps = [];
for (const [, s] of turn.steps) steps.push({ verb: s.verb, tgt: s.tgt, tool: s.tool, dur: s.dur, ok: s.ok });
pushMsg({ role: "assistant", text: turn.raw, steps });
turn = null;
saveChats();
}
// ---- static render of a stored assistant message (on chat reload) ------
function renderStoredAssistant(m) {
const sh = addAgentShell();
if (m.steps && m.steps.length) {
const c = document.createElement("div"); c.className = "console collapsed";
const rows = m.steps.map((s, i) => `<div class="step ${s.ok === false ? "err" : "done"}"><div class="step-node">${s.ok === false ? I.x : I.check}</div><div class="step-main"><div class="step-row"><div class="step-verb"><span class="v">${esc(s.verb || "")}</span>${chipFor(s.tgt, "")}<span class="step-tool">${esc(s.tool || "")}</span></div><span class="step-dur">${esc(s.dur || "")}</span></div></div></div>`).join("");
c.innerHTML = `<div class="console-head"><span class="console-check">${I.check}</span><div class="console-title">Готово <span class="console-meta">· ${m.steps.length} ${plural(m.steps.length)}</span></div><span class="console-chev">${I.chev}</span></div><div class="console-body"><div class="console-inner">${rows}</div></div>`;
c.querySelector(".console-head").addEventListener("click", () => c.classList.toggle("collapsed"));
sh.body.appendChild(c);
}
if (m.text && m.text.trim()) { const t = document.createElement("div"); t.className = "msg-text"; t.innerHTML = renderMarkdown(m.text); sh.body.appendChild(t); }
}
// ---- chat persistence --------------------------------------------------
function loadChats() { try { chats = JSON.parse(localStorage.getItem("mcp_chats_v1") || "[]"); } catch { chats = []; } activeId = localStorage.getItem("mcp_active") || null; }
function saveChats() { try { localStorage.setItem("mcp_chats_v1", JSON.stringify(chats.slice(0, 50))); if (activeId) localStorage.setItem("mcp_active", activeId); } catch {} }
function activeChat() { return chats.find((c) => c.id === activeId); }
function pushMsg(m) { const c = activeChat(); if (c) { c.msgs.push(m); c.at = Date.now(); } }
function renderSidebar() {
const groups = { "Сегодня": [], "Ранее": [] };
const today = new Date(); today.setHours(0, 0, 0, 0);
chats.slice().sort((a, b) => b.at - a.at).forEach((c) => { (c.at >= today.getTime() ? groups["Сегодня"] : groups["Ранее"]).push(c); });
let html = "";
for (const g of ["Сегодня", "Ранее"]) {
if (!groups[g].length) continue;
html += `<div class="sb-group-label">${g}</div>`;
for (const c of groups[g]) html += `<div class="chat-item ${c.id === activeId ? "active" : ""}" data-chat="${c.id}"><span class="ci-icon">${I.chat}</span><span class="chat-title">${esc(c.title)}</span><span class="ci-act" data-del="${c.id}">${I.trash}</span></div>`;
}
$("sbScroll").innerHTML = html || `<div class="sb-group-label">Сессии</div>`;
}
function renderThread() {
threadEl.innerHTML = "";
const c = activeChat();
if (!c || !c.msgs.length) { showEmptyState(); return; }
for (const m of c.msgs) { if (m.role === "user") addUserMessage(m.text, m.images); else if (m.role === "audio") renderStoredAudio(m); else renderStoredAssistant(m); }
scrollDown();
}
function newChat() {
const c = { id: "c" + Date.now().toString(36), title: "Новый чат", model, sessionId: null, at: Date.now(), msgs: [] };
chats.unshift(c); activeId = c.id; sessionId = null; turn = null;
$("convTitle").textContent = "Новый разговор"; $("convSub").textContent = "ue-blueprint MCP";
renderSidebar(); renderThread(); saveChats(); inputEl.focus();
}
function switchChat(id) {
const c = chats.find((x) => x.id === id); if (!c) return;
activeId = id; sessionId = c.sessionId || null; model = c.model || model; turn = null;
const mm = MODELS.find((m) => m.id === model); $("modelName").textContent = mm ? mm.name : model;
$("convTitle").textContent = c.title; updateConvSub(c);
renderSidebar(); renderThread(); saveChats();
}
function deleteChat(id) {
chats = chats.filter((c) => c.id !== id);
if (activeId === id) { activeId = chats[0] ? chats[0].id : null; if (activeId) switchChat(activeId); else newChat(); }
renderSidebar(); saveChats();
}
function updateConvSub(c) {
const n = (c && c.msgs ? c.msgs.reduce((a, m) => a + (m.steps ? m.steps.length : 0), 0) : 0);
$("convSub").textContent = (n ? n + " tool calls · " : "") + "ue-blueprint MCP";
}
// ---- empty state -------------------------------------------------------
function showEmptyState() {
const es = document.createElement("div"); es.className = "empty-state";
es.innerHTML = `<div><div class="es-icon">${I.brand}</div><h2>Forge — копилот внутри движка</h2><p>Я работаю с проектом через ue-blueprint MCP и могу править Blueprints, спавнить ноды, билдить C++, делать скриншоты UMG.</p><div class="suggest-grid">${sugg("Пингани UE", "проверь связь и активный Blueprint")}${sugg("Список MCP-инструментов", "что ты умеешь")}${sugg("Опиши активный Blueprint", "разбери граф событий")}${sugg("Открой /Game", "покажи структуру проекта")}</div></div>`;
threadEl.appendChild(es);
es.querySelectorAll(".suggest").forEach((s) => s.addEventListener("click", () => { inputEl.value = s.dataset.q; autoGrow(); inputEl.focus(); }));
}
function sugg(t, d) { return `<button class="suggest" data-q="${esc(t)}"><div class="sg-title">${esc(t)}</div><div class="sg-desc">${esc(d)}</div></button>`; }
// ---- event handling ----------------------------------------------------
function handleEvent(name, data) {
switch (name) {
case "init": sessionId = data.sessionId || sessionId; { const c = activeChat(); if (c) c.sessionId = sessionId; } if (data.tools) setMcp(data.tools); break;
case "tool_start": addStep(data.id, data.name, data.input); break;
case "tool_end": finishStep(data.id, data.isError, data.content); break;
case "text": appendText(data.delta || ""); break;
case "thinking": appendThinking(data.delta || ""); break;
case "error": { ensureText(); if (turn.caretEl) turn.caretEl.remove(); turn.textEl.insertAdjacentHTML("beforeend", `<div class="err-text">⚠ ${esc(data.message || "ошибка")}</div>`); setStatus("warn", "ошибка"); break; }
case "done": sessionId = data.sessionId || sessionId; break;
}
}
// ---- send --------------------------------------------------------------
async function send() {
const text = inputEl.value.trim();
if ((!text && !pending.length) || busy) return;
if (!pending.length && text.toLowerCase().startsWith("/sound ")) {
inputEl.value = ""; autoGrow();
await generateSounds(text.slice(7));
return;
}
closeMention();
busy = true; sendEl.disabled = true;
setStatus("on", "думает…");
const imgs = pending.slice(); pending = []; renderAttachRow();
const dataUrls = imgs.map((p) => p.dataUrl);
inputEl.value = ""; autoGrow();
// ensure there is an active chat
if (!activeChat()) newChat();
const chat = activeChat();
if (chat.msgs.length === 0) { chat.title = (text || "Изображение").slice(0, 48); $("convTitle").textContent = chat.title; renderSidebar(); }
const empty = threadEl.querySelector(".empty-state"); if (empty) empty.remove();
addUserMessage(text, dataUrls);
pushMsg({ role: "user", text, images: dataUrls });
saveChats();
turn = Object.assign(addAgentShell(), { console: null, stepsEl: null, steps: new Map(), nStep: 0, thinkEl: null, textEl: null, liveEl: null, caretEl: null, raw: "", t0: performance.now() });
// upload attachments → ids
let attachIds = [];
try {
for (const p of imgs) {
const r = await fetch("/api/upload", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ dataUrl: p.dataUrl }) }).then((x) => x.json());
if (r && r.ok) attachIds.push(r.id);
}
} catch {}
const qs = "message=" + encodeURIComponent(text) + "&model=" + encodeURIComponent(model) +
(sessionId ? "&sessionId=" + encodeURIComponent(sessionId) : "") +
(attachIds.length ? "&attach=" + attachIds.join(",") : "");
const es = new EventSource("/api/chat-stream?" + qs);
let finished = false, durMs = 0;
const finish = () => { if (finished) return; finished = true; try { es.close(); } catch {} finalizeTurn(durMs); const c = activeChat(); if (c) updateConvSub(c); busy = false; sendEl.disabled = false; setStatus("on", "online"); refreshHealth(); inputEl.focus(); };
["init", "text", "thinking", "tool_start", "tool_end"].forEach((nm) => es.addEventListener(nm, (e) => { let d; try { d = JSON.parse(e.data); } catch { return; } handleEvent(nm, d); }));
es.addEventListener("done", (e) => { let d = {}; try { d = JSON.parse(e.data); } catch {} durMs = d.durationMs || 0; handleEvent("done", d); finish(); });
es.addEventListener("error", (e) => { let d = null; try { d = JSON.parse(e.data); } catch {} if (d) handleEvent("error", d); else if (!finished && turn) { ensureText(); if (turn.caretEl) turn.caretEl.remove(); turn.textEl.insertAdjacentHTML("beforeend", `<div class="err-text">⚠ потеряна связь с гейтвеем</div>`); setStatus("off", "offline"); } finish(); });
}
// ---- integration settings / ElevenLabs -------------------------------
function openModal(id) { $(id).style.display = "grid"; }
function closeModal(id) { $(id).style.display = "none"; }
function setIntegrationState(id, connected, text) {
const el = $(id); el.textContent = text; el.classList.toggle("connected", !!connected);
}
async function loadSettings() {
const settings = await fetch("/api/settings").then((r) => r.json());
const eleven = settings.elevenlabs || {}, codex = settings.codex || {};
$("elevenApiKey").value = "";
$("elevenApiKey").placeholder = eleven.apiKeyMasked || "xi-api-key";
$("elevenKeyHint").textContent = eleven.configured ? `Connected as ${eleven.apiKeyMasked}. Leave blank to keep this key.` : "Enter a key to connect ElevenLabs.";
$("elevenImportPath").value = eleven.importPath || "/Game/Audio/Generated";
$("elevenVariations").value = String(eleven.variationCount || 3);
$("elevenSoundModel").value = eleven.modelId || "eleven_text_to_sound_v2";
$("elevenDefaultDuration").value = eleven.durationSeconds == null ? "" : String(eleven.durationSeconds);
$("elevenDefaultInfluence").value = String(eleven.promptInfluence ?? 0.3);
$("soundVariations").value = String(eleven.variationCount || 3);
$("soundDuration").value = eleven.durationSeconds == null ? "" : String(eleven.durationSeconds);
$("soundInfluence").value = String(Math.round((eleven.promptInfluence ?? 0.3) * 100));
$("soundInfluenceValue").textContent = $("soundInfluence").value + "%";
setSoundLoop(!!eleven.loop);
$("textFixPrompt").value = settings.textTools?.improvePrompt || "";
$("translateLanguages").value = (settings.textTools?.translateLanguages || []).join(", ");
setIntegrationState("elevenState", eleven.configured, eleven.configured ? "Connected" : "Not configured");
setIntegrationState("codexState", codex.connected, codex.connected ? "OAuth connected" : "Not connected");
$("codexLogin").textContent = codex.connected ? "Re-authorize OAuth" : "Authorize with OAuth";
return settings;
}
async function openSettings() {
$("userPop").style.display = "none"; $("userRow").classList.remove("open");
openModal("settingsModal");
try { await loadSettings(); } catch { toast("Could not load settings"); }
}
async function saveSettings() {
const key = $("elevenApiKey").value.trim();
const elevenlabs = {
importPath: $("elevenImportPath").value.trim(),
variationCount: +$("elevenVariations").value,
modelId: $("elevenSoundModel").value,
durationSeconds: $("elevenDefaultDuration").value || null,
promptInfluence: +$("elevenDefaultInfluence").value,
};
const textTools = { improvePrompt: $("textFixPrompt").value.trim(), translateLanguages: $("translateLanguages").value.split(",").map((x) => x.trim()).filter(Boolean) };
if (key) elevenlabs.apiKey = key;
const btn = $("saveSettings"); btn.disabled = true;
try {
const result = await fetch("/api/settings", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ elevenlabs, textTools }) }).then((r) => r.json());
if (!result.ok) throw new Error(result.error || "Could not save settings");
closeModal("settingsModal"); toast("Integration settings saved");
} catch (e) { toast(e.message || "Could not save settings"); }
finally { btn.disabled = false; }
}
async function generateSounds(promptOverride) {
const prompt = (typeof promptOverride === "string" ? promptOverride : $("soundPrompt").value).trim();
if (!prompt) { toast("Describe the sound first"); return; }
const btn = $("generateSound"); btn.disabled = true; btn.textContent = "Generating...";
closeModal("soundModal");
if (!activeChat()) newChat();
const chat = activeChat();
if (!chat.msgs.length) { chat.title = `Sound: ${prompt}`.slice(0, 48); $("convTitle").textContent = chat.title; renderSidebar(); }
const empty = threadEl.querySelector(".empty-state"); if (empty) empty.remove();
addUserMessage(`/sound ${prompt}`);
pushMsg({ role: "user", text: `/sound ${prompt}` }); saveChats();
const shell = addAgentShell(), working = document.createElement("div");
working.className = "sound-working"; working.innerHTML = `<span class="console-spin"></span><span>Generating ElevenLabs variations...</span>`;
shell.body.appendChild(working); scrollDown();
try {
const result = await fetch("/api/elevenlabs/generate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({
prompt,
loop: $("soundLoop").classList.contains("on"),
durationSeconds: $("soundDuration").value || null,
promptInfluence: +$("soundInfluence").value / 100,
variationCount: +$("soundVariations").value,
modelId: "eleven_text_to_sound_v2",
}) }).then((r) => r.json());
if (!result.ok) throw new Error(result.error || "Sound generation failed");
shell.el.remove();
addAudioMessage(result.prompt, result.sounds);
toast(`${result.sounds.length} sound variations ready`);
} catch (e) {
working.innerHTML = `<span class="err-text">${esc(e.message || "Sound generation failed")}</span>`;
toast("Sound generation failed");
} finally {
btn.disabled = false; btn.textContent = "Generate variations";
}
}
async function runDraftTool(endpoint, payload, button, workingText) {
const text = inputEl.value.trim();
if (!text) { toast("Write a draft first"); inputEl.focus(); return; }
const original = button.innerHTML; button.disabled = true; button.textContent = workingText;
try {
const result = await fetch(endpoint, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text, ...payload }) }).then((r) => r.json());
if (!result.ok) throw new Error(result.error || "Text tool failed");
inputEl.value = result.text; autoGrow(); inputEl.focus(); toast("Draft updated");
} catch (e) { toast(e.message || "Text tool failed"); }
finally { button.disabled = false; button.innerHTML = original; }
}
function setSoundLoop(on) {
$("soundLoop").classList.toggle("on", on);
$("soundLoop").setAttribute("aria-pressed", on ? "true" : "false");
$("soundLoop").querySelector("strong").textContent = on ? "On" : "Off";
}
// ---- asset chip clicks -------------------------------------------------
threadEl.addEventListener("click", (e) => {
const playBtn = e.target.closest("[data-player-play]");
if (playBtn) {
const card = playBtn.closest("[data-audio-card]"), audio = card.querySelector("[data-player-audio]");
if (audio.paused) { stopOtherPlayers(audio); audio.play().catch(() => toast("Could not play this sound")); } else audio.pause();
syncPlayer(card);
return;
}
const importBtn = e.target.closest("[data-import-audio]");
if (importBtn) {
if (importBtn.classList.contains("done")) return;
importBtn.disabled = true; importBtn.textContent = "Importing...";
fetch("/api/elevenlabs/import", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: importBtn.getAttribute("data-import-audio") }) })
.then((r) => r.json()).then((result) => {
if (!result.ok) throw new Error(result.error || "Import failed");
importBtn.classList.add("done"); importBtn.textContent = "Imported";
const sound = chats.flatMap((c) => c.msgs || []).filter((m) => m.role === "audio").flatMap((m) => m.sounds || []).find((s) => s.id === importBtn.getAttribute("data-import-audio"));
if (sound) { sound.imported = true; sound.assetPath = result.paths && result.paths[0]; saveChats(); }
toast(result.paths && result.paths[0] ? `Imported: ${result.paths[0]}` : "Sound imported");
}).catch((err) => { importBtn.disabled = false; importBtn.innerHTML = `${I.download} Import`; toast(err.message || "Import failed"); });
return;
}
const chip = e.target.closest("[data-asset]"); if (!chip) return;
const path = chip.getAttribute("data-asset"), copy = chip.getAttribute("data-copy") || path;
if (copy) { try { navigator.clipboard.writeText(copy); } catch {} }
if (path && path.startsWith("/Game/")) {
fetch("/api/focus", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path }) })
.then((r) => r.json()).then((res) => toast(res.ok ? "→ выделено в Content Browser" : "путь скопирован")).catch(() => toast("путь скопирован"));
} else if (copy) toast("скопировано: " + copy);
});
threadEl.addEventListener("input", (e) => {
const card = e.target.closest("[data-audio-card]"); if (!card) return;
const audio = card.querySelector("[data-player-audio]");
if (e.target.matches("[data-player-progress]") && audio.duration) audio.currentTime = (+e.target.value / 1000) * audio.duration;
if (e.target.matches("[data-player-volume]")) audio.volume = +e.target.value / 100;
syncPlayer(card);
});
["loadedmetadata", "timeupdate", "play", "pause", "ended"].forEach((name) => threadEl.addEventListener(name, (e) => {
if (!e.target.matches?.("[data-player-audio]")) return;
syncPlayer(e.target.closest("[data-audio-card]"));
}, true));
// ---- sidebar clicks ----------------------------------------------------
$("sbScroll").addEventListener("click", (e) => {
const del = e.target.closest("[data-del]"); if (del) { e.stopPropagation(); deleteChat(del.getAttribute("data-del")); return; }
const item = e.target.closest("[data-chat]"); if (item) switchChat(item.getAttribute("data-chat"));
});
// ---- model popover -----------------------------------------------------
function toggleModelPop() {
if (modelPop.style.display === "block") { modelPop.style.display = "none"; return; }
modelPop.innerHTML = `<div class="pop-label">Модель</div>` + MODELS.map((m) =>
`<div class="pop-item ${m.disabled ? "disabled" : ""}" data-model="${m.id}" data-disabled="${m.disabled ? "1" : ""}"><span class="m-glyph" style="width:18px;height:18px">${I.spark}</span><div class="pi-main"><div class="pi-name">${esc(m.name)}</div><div class="pi-desc">${esc(m.tag || m.id)}</div></div>${m.id === model ? `<span class="pi-check">${I.check}</span>` : ""}</div>`).join("");
modelPop.style.display = "block";
}
modelPop.addEventListener("click", (e) => {
const it = e.target.closest("[data-model]"); if (!it) return;
if (it.getAttribute("data-disabled")) { toast("Codex OAuth is connected, but Forge needs a Codex runtime transport before it can use this model"); return; }
const id = it.getAttribute("data-model"); modelPop.style.display = "none";
if (id === model) return;
model = id; localStorage.setItem("mcp_model", model);
const mm = MODELS.find((m) => m.id === model); $("modelName").textContent = mm ? mm.name : model;
const c = activeChat();
if (c && c.msgs.length) { toast("Модель: " + (mm ? mm.name : model) + " — новый чат"); newChat(); }
else { if (c) c.model = model; toast("Модель: " + (mm ? mm.name : model)); }
});
document.addEventListener("click", (e) => { if (!e.target.closest("#modelBtn") && !e.target.closest("#modelPop")) modelPop.style.display = "none"; });
// ---- @mention autocomplete --------------------------------------------
let menItems = [], menHl = 0, menTimer = null, menActive = false;
function currentToken() {
const pos = inputEl.selectionStart; const before = inputEl.value.slice(0, pos);
const m = before.match(/@([^\s@]*)$/); return m ? { q: m[1], start: pos - m[0].length, end: pos } : null;
}
function openMention(q) {
menActive = true;
clearTimeout(menTimer);
menTimer = setTimeout(() => {
fetch("/api/assets?q=" + encodeURIComponent(q)).then((r) => r.json()).then((j) => {
if (!menActive) return;
menItems = j.assets || []; menHl = 0; renderMention();
}).catch(() => {});
}, 160);
}
function renderMention() {
if (!menItems.length) { mentionPop.innerHTML = `<div class="mp-empty">ничего не найдено</div>`; mentionPop.style.display = "block"; return; }
mentionPop.innerHTML = menItems.map((a, i) =>
`<div class="mp-row ${i === menHl ? "hl" : ""}" data-i="${i}"><span class="mp-ico">${I.asset}</span><div class="mp-main"><div class="mp-name">${esc(a.name)} ${a.open ? '<span class="mp-open">OPEN</span>' : ""}</div><div class="mp-path">${esc(a.path)}</div></div><span class="mp-cls">${esc(a.class || "")}</span></div>`).join("");
mentionPop.style.display = "block";
}
function closeMention() { menActive = false; mentionPop.style.display = "none"; menItems = []; }
function acceptMention(i) {
const a = menItems[i]; if (!a) return;
const tok = currentToken(); if (!tok) { closeMention(); return; }
const v = inputEl.value, ins = "@" + a.path + " ";
inputEl.value = v.slice(0, tok.start) + ins + v.slice(tok.end);
const caret = tok.start + ins.length; inputEl.setSelectionRange(caret, caret);
closeMention(); autoGrow(); inputEl.focus();
}
mentionPop.addEventListener("click", (e) => { const r = e.target.closest("[data-i]"); if (r) acceptMention(+r.getAttribute("data-i")); });
// ---- attachments -------------------------------------------------------
function renderAttachRow() {
const row = $("attachRow");
if (!pending.length) { row.style.display = "none"; row.innerHTML = ""; return; }
row.style.display = "flex";
row.innerHTML = pending.map((p, i) => `<div class="attach-thumb"><img src="${p.dataUrl}" /><span class="at-x" data-rm="${i}">${I.x}</span></div>`).join("");
}
$("attachRow").addEventListener("click", (e) => { const rm = e.target.closest("[data-rm]"); if (rm) { pending.splice(+rm.getAttribute("data-rm"), 1); renderAttachRow(); } });
function addFile(file) {
if (!file || !file.type.startsWith("image/")) return;
const reader = new FileReader();
reader.onload = () => { pending.push({ dataUrl: reader.result, media: file.type, name: file.name || "image" }); renderAttachRow(); };
reader.readAsDataURL(file);
}
$("attachBtn").addEventListener("click", () => $("fileInput").click());
$("fileInput").addEventListener("change", (e) => { for (const f of e.target.files) addFile(f); e.target.value = ""; });
inputEl.addEventListener("paste", (e) => { const items = (e.clipboardData || {}).items || []; for (const it of items) { if (it.kind === "file" && it.type.startsWith("image/")) { addFile(it.getAsFile()); e.preventDefault(); } } });
// drag & drop onto composer
const composer = $("composer");
["dragover", "drop"].forEach((ev) => composer.addEventListener(ev, (e) => { e.preventDefault(); if (ev === "drop") for (const f of e.dataTransfer.files) addFile(f); }));
// ---- composer input ----------------------------------------------------
function autoGrow() { inputEl.style.height = "auto"; inputEl.style.height = Math.min(inputEl.scrollHeight, 200) + "px"; }
inputEl.addEventListener("input", () => {
autoGrow();
const tok = currentToken();
if (tok && tok.q.length >= 0 && !tok.q.startsWith("/")) openMention(tok.q);
else if (tok && tok.q.startsWith("/")) openMention(tok.q);
else closeMention();
});
inputEl.addEventListener("focus", () => composer.classList.add("focus"));
inputEl.addEventListener("blur", () => { composer.classList.remove("focus"); setTimeout(closeMention, 150); });
inputEl.addEventListener("keydown", (e) => {
if (mentionPop.style.display === "block" && menItems.length) {
if (e.key === "ArrowDown") { e.preventDefault(); menHl = (menHl + 1) % menItems.length; renderMention(); return; }
if (e.key === "ArrowUp") { e.preventDefault(); menHl = (menHl - 1 + menItems.length) % menItems.length; renderMention(); return; }
if (e.key === "Enter" || e.key === "Tab") { e.preventDefault(); acceptMention(menHl); return; }
if (e.key === "Escape") { e.preventDefault(); closeMention(); return; }
}
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send(); }
});
$("mentionBtn").addEventListener("click", () => { const pos = inputEl.value.length; inputEl.value += (inputEl.value.endsWith(" ") || !inputEl.value ? "" : " ") + "@"; inputEl.focus(); autoGrow(); openMention(""); });
sendEl.addEventListener("click", send);
$("modelBtn").addEventListener("click", toggleModelPop);
$("newChat").addEventListener("click", newChat);
$("userRow").addEventListener("click", () => {
const open = $("userPop").style.display === "block";
$("userPop").style.display = open ? "none" : "block";
$("userRow").classList.toggle("open", !open);
});
$("settingsBtn").addEventListener("click", openSettings);
$("saveSettings").addEventListener("click", saveSettings);
$("soundBtn").addEventListener("click", async () => {
try {
const settings = await loadSettings();
if (!settings.elevenlabs.configured) { openSettings(); toast("Add your ElevenLabs API key first"); return; }
openModal("soundModal"); $("soundPrompt").focus();
} catch { openSettings(); }
});
$("generateSound").addEventListener("click", generateSounds);
$("soundPrompt").addEventListener("input", () => { $("soundPromptCount").textContent = $("soundPrompt").value.length; });
$("soundLoop").addEventListener("click", () => setSoundLoop(!$("soundLoop").classList.contains("on")));
$("soundInfluence").addEventListener("input", () => { $("soundInfluenceValue").textContent = $("soundInfluence").value + "%"; });
$("textFixBtn").addEventListener("click", () => runDraftTool("/api/text-tools/improve", {}, $("textFixBtn"), "Fixing..."));
$("translateBtn").addEventListener("click", async () => {
if (!inputEl.value.trim()) { toast("Write a draft first"); inputEl.focus(); return; }
try {
const settings = await loadSettings();
const languages = settings.textTools?.translateLanguages || [];
$("translateLanguage").innerHTML = languages.map((language) => `<option>${esc(language)}</option>`).join("");
openModal("translateModal");
} catch { toast("Could not load translation languages"); }
});
$("translateDraft").addEventListener("click", async () => {
closeModal("translateModal");
await runDraftTool("/api/text-tools/translate", { language: $("translateLanguage").value }, $("translateBtn"), "Translating...");
});
$("codexLogin").addEventListener("click", async () => {
const result = await fetch("/api/integrations/codex/login", { method: "POST" }).then((r) => r.json()).catch(() => ({ ok: false }));
toast(result.ok ? "Codex OAuth login opened" : "Could not start Codex OAuth login");
});
document.querySelectorAll("[data-close-modal]").forEach((btn) => btn.addEventListener("click", () => closeModal(btn.getAttribute("data-close-modal"))));
document.querySelectorAll(".modal-backdrop").forEach((backdrop) => backdrop.addEventListener("click", (e) => { if (e.target === backdrop) closeModal(backdrop.id); }));
document.addEventListener("click", (e) => {
if (!e.target.closest("#userRow") && !e.target.closest("#userPop")) { $("userPop").style.display = "none"; $("userRow").classList.remove("open"); }
});
document.addEventListener("keydown", (e) => { if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "n") { e.preventDefault(); newChat(); } });
// ---- boot --------------------------------------------------------------
mountIcons();
loadChats();
refreshHealth().then(() => pollTools(5));
if (chats.length && activeId && activeChat()) { switchChat(activeId); } else if (chats.length) { switchChat(chats[0].id); } else { newChat(); }
inputEl.focus();
})();

View File

@ -0,0 +1,194 @@
<!DOCTYPE html>
<html lang="en" data-theme="eclipse">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Unreal Copilot</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<div class="app">
<!-- ============ SIDEBAR ============ -->
<aside class="sidebar">
<div class="sb-head">
<div class="brand">
<div class="brand-mark" id="brandMark"></div>
<div>
<div class="brand-name">Unreal Copilot</div>
<div class="brand-sub">ue-blueprint MCP</div>
</div>
</div>
<button class="icon-btn" id="historyBtn" title="История"></button>
</div>
<button class="new-chat" id="newChat">
<span id="newChatIcon"></span>
<span>Новый чат</span>
<span class="kbd">Ctrl N</span>
</button>
<div class="sb-search">
<span id="searchIcon"></span>
<input id="search" placeholder="Поиск по чатам…" />
</div>
<div class="sb-scroll" id="sbScroll"></div>
<div class="sb-foot">
<div class="mcp-card">
<div class="mcp-head" id="mcpHead">
<span id="mcpIcon"></span>
<span class="mcp-title">MCP Servers</span>
<span class="mcp-count" id="mcpCount"></span>
</div>
<div class="mcp-list" id="mcpList"></div>
</div>
<div class="user-row" id="userRow">
<div class="avatar" id="userAvatar">EX</div>
<div class="user-copy">
<div class="user-name">Exbyte Studios</div>
<div class="user-plan">Studio · Pro</div>
</div>
<span class="user-chev" id="userChev"></span>
</div>
<div class="user-pop pop" id="userPop" style="display:none">
<div class="pop-label">Workspace</div>
<div class="pop-item" id="settingsBtn"><span class="pop-ico" id="settingsIcon"></span><div class="pi-main"><div class="pi-name">Settings</div><div class="pi-desc">Integrations and import paths</div></div></div>
</div>
</div>
</aside>
<!-- ============ MAIN ============ -->
<main class="main">
<header class="topbar">
<div class="tb-titles">
<div class="tb-title" id="convTitle">Новый разговор</div>
<div class="tb-sub" id="convSub">ue-blueprint MCP</div>
</div>
<div class="tb-spacer"></div>
<div class="status-pill" id="statusPill"><span class="dot off" id="statusDot"></span><span id="statusText">connecting…</span></div>
<div style="position:relative">
<button class="model-btn" id="modelBtn">
<span class="m-glyph" id="modelGlyph"></span>
<span id="modelName">agent</span>
<span id="modelChev" style="color:var(--text-dim)"></span>
</button>
<div class="pop" id="modelPop" style="display:none; right:0; top:40px;"></div>
</div>
</header>
<div class="thread-wrap" id="threadWrap">
<div class="thread" id="thread"></div>
</div>
<div class="composer-wrap">
<div class="composer-inner">
<div class="attach-row" id="attachRow" style="display:none"></div>
<div class="composer" id="composer">
<textarea class="composer-field" id="input" rows="1" placeholder="Попроси Forge отредактировать Blueprint или @упомяни ассет…"></textarea>
<div class="composer-bar">
<button class="cb-btn" id="mentionBtn"><span id="mentionIcon"></span> Mention</button>
<button class="cb-btn" id="attachBtn"><span id="attachIcon"></span> Attach</button>
<button class="cb-btn" id="toolsBtn"><span id="toolsIcon"></span> Tools <span id="toolsCount" style="color:var(--text-dim)"></span></button>
<button class="cb-btn" id="soundBtn"><span id="soundIcon"></span> Sound</button>
<button class="cb-btn" id="textFixBtn"><span id="textFixIcon"></span> Text Fix</button>
<button class="cb-btn" id="translateBtn"><span id="translateIcon"></span> Translate</button>
<span class="cb-spacer"></span>
<button class="send-btn" id="send" title="Отправить"><span id="sendIcon"></span></button>
</div>
</div>
<div class="mention-pop pop" id="mentionPop" style="display:none"></div>
<div class="composer-hint">Forge правит Blueprints и C++ через <strong style="color:var(--text-muted);font-weight:600">ue-blueprint</strong> MCP · <span class="kbd"></span> отправить · <span class="kbd">⇧⏎</span> перенос</div>
</div>
</div>
</main>
</div>
<input type="file" id="fileInput" accept="image/*" multiple style="display:none" />
<div class="modal-backdrop" id="settingsModal" style="display:none">
<div class="modal settings-modal">
<div class="modal-head">
<div><div class="modal-title">Settings</div><div class="modal-sub">Forge workspace configuration</div></div>
<button class="modal-close" data-close-modal="settingsModal">x</button>
</div>
<div class="settings-body">
<div class="settings-nav">
<button class="settings-nav-item active"><span id="integrationsIcon"></span> Integrations</button>
</div>
<div class="settings-content">
<div class="settings-kicker">Integrations</div>
<h2>Connected services</h2>
<p class="settings-intro">Secrets are stored locally beside the Forge gateway and never returned to the browser.</p>
<section class="integration-card">
<div class="integration-head">
<div class="integration-logo eleven">XI</div>
<div class="integration-meta"><h3>ElevenLabs</h3><p>Generate sound-effect variations and import selected WAV assets into Unreal.</p></div>
<span class="integration-state" id="elevenState">Not configured</span>
</div>
<label class="field"><span>API key</span><input id="elevenApiKey" type="password" autocomplete="off" placeholder="xi-api-key" /><small id="elevenKeyHint">Enter a key to connect ElevenLabs.</small></label>
<div class="field-grid">
<label class="field"><span>Unreal import folder</span><input id="elevenImportPath" value="/Game/Audio/Generated" /></label>
<label class="field"><span>Variations per prompt</span><select id="elevenVariations"><option>1</option><option>2</option><option selected>3</option><option>4</option></select></label>
</div>
<label class="field"><span>Sound Effects model</span><select id="elevenSoundModel"><option value="eleven_text_to_sound_v2">Sound Effects v2 · eleven_text_to_sound_v2</option></select><small>Current ElevenLabs flagship sound-effects model. Looping is supported by v2.</small></label>
<div class="field-grid">
<label class="field"><span>Default duration</span><select id="elevenDefaultDuration"><option value="">Auto</option><option value="1">1s</option><option value="2">2s</option><option value="3">3s</option><option value="5">5s</option><option value="10">10s</option><option value="15">15s</option><option value="30">30s</option></select></label>
<label class="field"><span>Default prompt influence</span><select id="elevenDefaultInfluence"><option value="0.2">20% · more variety</option><option value="0.3">30% · balanced</option><option value="0.5">50% · closer prompt</option><option value="0.7">70% · strict prompt</option><option value="0.9">90% · very strict</option></select></label>
</div>
</section>
<section class="integration-card">
<div class="integration-head">
<div class="integration-logo codex">&lt;/&gt;</div>
<div class="integration-meta"><h3>Codex</h3><p>Authorize Codex locally through OAuth. Forge only reads the connection state.</p></div>
<span class="integration-state" id="codexState">Checking...</span>
</div>
<div class="integration-actions"><button class="secondary-btn" id="codexLogin">Authorize with OAuth</button><span class="integration-note">OAuth credentials stay in <code>~/.codex/auth.json</code>.</span></div>
</section>
<section class="integration-card">
<div class="integration-head">
<div class="integration-logo textfix">Aa</div>
<div class="integration-meta"><h3>Text Fix Improver</h3><p>Improve composer drafts with your own editing instruction.</p></div>
<span class="integration-state connected">Enabled</span>
</div>
<label class="field"><span>Improvement prompt</span><textarea id="textFixPrompt" rows="4" maxlength="2000"></textarea><small>Forge returns only the improved text and replaces the current draft.</small></label>
<label class="field"><span>Translate languages</span><input id="translateLanguages" placeholder="English, Russian, German" /><small>Comma-separated languages shown in the Translate dialog.</small></label>
</section>
</div>
</div>
<div class="modal-foot"><button class="secondary-btn" data-close-modal="settingsModal">Cancel</button><button class="primary-btn" id="saveSettings">Save changes</button></div>
</div>
</div>
<div class="modal-backdrop" id="soundModal" style="display:none">
<div class="modal sound-modal">
<div class="modal-head">
<div><div class="modal-title">Generate sound effects</div><div class="modal-sub">ElevenLabs creates multiple variations for review in this chat.</div></div>
<button class="modal-close" data-close-modal="soundModal">x</button>
</div>
<div class="sound-modal-body">
<label class="field"><span>Sound prompt</span><textarea id="soundPrompt" rows="5" maxlength="450" placeholder="Heavy metal door slamming shut in a damp industrial corridor, horror game one-shot"></textarea><small><span id="soundPromptCount">0</span>/450 characters</small></label>
<div class="sound-options">
<label class="sound-option"><span class="sound-option-label">Loop</span><button class="toggle-btn" id="soundLoop" type="button" aria-pressed="false"><span class="toggle-track"><span class="toggle-knob"></span></span><strong>Off</strong></button></label>
<label class="sound-option"><span class="sound-option-label">Duration</span><select id="soundDuration"><option value="">Auto</option><option value="1">1s</option><option value="2">2s</option><option value="3">3s</option><option value="5">5s</option><option value="10">10s</option><option value="15">15s</option><option value="30">30s</option></select></label>
<label class="sound-option influence"><span class="sound-option-label">Prompt influence</span><div class="range-row"><input id="soundInfluence" type="range" min="0" max="100" value="30" step="5" /><strong id="soundInfluenceValue">30%</strong></div></label>
<label class="sound-option compact"><span class="sound-option-label">Variations</span><select id="soundVariations"><option>1</option><option>2</option><option selected>3</option><option>4</option></select></label>
</div>
</div>
<div class="modal-foot"><button class="secondary-btn" data-close-modal="soundModal">Cancel</button><button class="primary-btn" id="generateSound">Generate variations</button></div>
</div>
</div>
<div class="modal-backdrop" id="translateModal" style="display:none">
<div class="modal translate-modal">
<div class="modal-head">
<div><div class="modal-title">Translate draft</div><div class="modal-sub">Choose the language for the current composer text.</div></div>
<button class="modal-close" data-close-modal="translateModal">x</button>
</div>
<div class="sound-modal-body">
<label class="field"><span>Target language</span><select id="translateLanguage"></select></label>
</div>
<div class="modal-foot"><button class="secondary-btn" data-close-modal="translateModal">Cancel</button><button class="primary-btn" id="translateDraft">Translate</button></div>
</div>
</div>
<div class="toast" id="toast"></div>
<script src="app.js"></script>
</body>
</html>

View File

@ -0,0 +1,369 @@
/* ============================================================
In-engine agent — "Forge / Unreal Copilot" eclipse theme.
Adapted for UE's CEF WebBrowser: no backdrop-filter / color-mix /
oklch (CPU-composited, possibly older Chromium) — explicit colors only.
All visual tokens live in :root.
No web-font @import — UE's CEF may have no/slow internet and a render-blocking
font fetch stalls the whole UI. System fonts only.
============================================================ */
:root {
--font-ui: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--font-mono: ui-monospace, "Cascadia Code", "Cascadia Mono", Consolas, monospace;
--radius-sm: 7px;
--radius: 11px;
--radius-lg: 16px;
--accent: #7C6BD6;
--accent-2: #9D7BFF;
--accent-soft: rgba(124,107,214,0.16);
--accent-line: rgba(124,107,214,0.40);
--bg: #0a0a0c;
--bg-grad: radial-gradient(120% 90% at 80% -10%, #15131c 0%, #0a0a0c 55%);
--bg-elev: #0d0d10;
--surface: #141418;
--surface-2: #1a1a20;
--surface-3: #212129;
--surface-trans: rgba(20,20,24,0.70);
--border: rgba(255,255,255,0.07);
--border-strong: rgba(255,255,255,0.12);
--text: #ECECF0;
--text-muted: #9a9aa6;
--text-dim: #62626e;
--ok: #4ec98f;
--warn: #e6b450;
--err: #ff7a7a;
--shadow: 0 1px 2px rgba(0,0,0,0.4), 0 8px 28px -12px rgba(0,0,0,0.55);
--shadow-pop: 0 18px 50px -16px rgba(0,0,0,0.7);
--sidebar-w: 276px;
--ease: cubic-bezier(0.32, 0.72, 0, 1);
--ease-out: cubic-bezier(0.22, 1, 0.36, 1);
}
* { box-sizing: border-box; }
html, body {
margin: 0; height: 100%;
background: var(--bg);
color: var(--text);
font-family: var(--font-ui);
font-size: 14px;
-webkit-font-smoothing: antialiased;
}
body { overflow: hidden; }
::selection { background: var(--accent-soft); color: var(--text); }
button { font-family: inherit; cursor: pointer; border: none; background: none; color: inherit; }
input, textarea { font-family: inherit; }
*::-webkit-scrollbar { width: 10px; height: 10px; }
*::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 99px; border: 3px solid transparent; background-clip: padding-box; }
*::-webkit-scrollbar-thumb:hover { background: var(--text-dim); background-clip: padding-box; }
*::-webkit-scrollbar-track { background: transparent; }
.app {
height: 100vh;
display: grid;
grid-template-columns: var(--sidebar-w) 1fr;
background: var(--bg-grad), var(--bg);
}
/* ---------------- SIDEBAR ---------------- */
.sidebar { background: var(--bg-elev); border-right: 1px solid var(--border); display: flex; flex-direction: column; min-width: 0; height: 100vh; }
.sb-head { padding: 16px 16px 12px; display: flex; align-items: center; gap: 10px; }
.brand { display: flex; align-items: center; gap: 10px; flex: 1; min-width: 0; }
.brand-mark { width: 30px; height: 30px; border-radius: 9px; flex-shrink: 0; display: grid; place-items: center;
background: linear-gradient(150deg, var(--accent) 0%, var(--accent-2) 100%); color: #fff; }
.brand-name { font-weight: 600; font-size: 15px; letter-spacing: -0.01em; }
.brand-sub { font-size: 11px; color: var(--text-dim); margin-top: -1px; letter-spacing: 0.02em; }
.icon-btn { width: 30px; height: 30px; border-radius: var(--radius-sm); display: grid; place-items: center; color: var(--text-muted); transition: background .15s, color .15s; }
.icon-btn:hover { background: var(--surface-2); color: var(--text); }
.new-chat { margin: 4px 12px 8px; padding: 9px 12px; display: flex; align-items: center; gap: 9px;
border-radius: var(--radius-sm); font-size: 13.5px; font-weight: 500; color: var(--text);
background: var(--surface); border: 1px solid var(--border); transition: border-color .15s, background .15s, transform .08s; }
.new-chat:hover { border-color: var(--accent-line); background: var(--surface-2); }
.new-chat:active { transform: translateY(1px); }
.new-chat .kbd { margin-left: auto; }
.kbd { font-family: var(--font-mono); font-size: 10.5px; color: var(--text-dim); border: 1px solid var(--border); border-radius: 5px; padding: 1px 5px; background: var(--bg); }
.sb-search { margin: 2px 12px 8px; display: flex; align-items: center; gap: 8px; padding: 7px 10px;
border-radius: var(--radius-sm); background: var(--bg); border: 1px solid var(--border); color: var(--text-dim); }
.sb-search input { flex: 1; background: none; border: none; outline: none; color: var(--text); font-size: 13px; }
.sb-search input::placeholder { color: var(--text-dim); }
.sb-scroll { flex: 1; overflow-y: auto; padding: 4px 8px 8px; }
.sb-group-label { font-size: 10.5px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-dim); padding: 14px 8px 5px; }
.chat-item { display: flex; align-items: center; gap: 9px; padding: 7px 9px; border-radius: var(--radius-sm); cursor: pointer;
color: var(--text-muted); font-size: 13px; position: relative; transition: background .13s, color .13s; }
.chat-item:hover { background: var(--surface); color: var(--text); }
.chat-item.active { background: var(--surface-2); color: var(--text); }
.chat-item.active::before { content: ''; position: absolute; left: -8px; top: 50%; transform: translateY(-50%); width: 3px; height: 16px; border-radius: 0 3px 3px 0; background: var(--accent); }
.chat-item .ci-icon { color: var(--text-dim); flex-shrink: 0; display: grid; place-items: center; }
.chat-item.active .ci-icon { color: var(--accent-2); }
.chat-title { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.sb-foot { border-top: 1px solid var(--border); padding: 8px; }
.mcp-card { border-radius: var(--radius); background: var(--surface); border: 1px solid var(--border); overflow: hidden; }
.mcp-head { display: flex; align-items: center; gap: 9px; padding: 10px 11px; cursor: pointer; transition: background .13s; }
.mcp-head:hover { background: var(--surface-2); }
.mcp-title { font-size: 12.5px; font-weight: 600; flex: 1; }
.mcp-count { font-size: 11px; color: var(--text-dim); font-family: var(--font-mono); }
.mcp-list { border-top: 1px solid var(--border); padding: 4px; }
.mcp-srv { display: flex; align-items: center; gap: 9px; padding: 7px 8px; border-radius: var(--radius-sm); font-size: 12.5px; }
.mcp-srv:hover { background: var(--surface-2); }
.mcp-srv .srv-name { flex: 1; }
.mcp-srv .srv-tools { font-size: 10.5px; color: var(--text-dim); font-family: var(--font-mono); }
.srv-self { font-size: 9.5px; font-weight: 600; letter-spacing: .04em; color: var(--accent-2); border: 1px solid var(--accent-line); border-radius: 4px; padding: 1px 5px; }
.dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
.dot.on { background: var(--ok); box-shadow: 0 0 0 3px rgba(78,201,143,0.22); }
.dot.off { background: var(--text-dim); }
.dot.warn{ background: var(--warn); }
.user-row { display: flex; align-items: center; gap: 10px; padding: 9px 6px 4px; border-radius: var(--radius-sm); cursor: pointer; transition: background .13s; }
.user-row:hover { background: var(--surface-2); }
.user-copy { flex: 1; min-width: 0; }
.user-chev { display: grid; place-items: center; color: var(--text-dim); transition: transform .16s; }
.user-row.open .user-chev { transform: rotate(180deg); }
.sb-foot { position: relative; }
.user-pop { left: 8px; right: 8px; bottom: 58px; min-width: 0; }
.avatar { width: 28px; height: 28px; border-radius: 8px; background: linear-gradient(135deg,#5b6cff,#c45bff); display: grid; place-items: center; color: #fff; font-size: 12px; font-weight: 600; }
.user-name { font-size: 12.5px; font-weight: 500; }
.user-plan { font-size: 10.5px; color: var(--text-dim); }
/* ---------------- MAIN / TOPBAR ---------------- */
.main { display: flex; flex-direction: column; min-width: 0; height: 100vh; }
.topbar { height: 54px; flex-shrink: 0; display: flex; align-items: center; gap: 12px; padding: 0 16px 0 18px; border-bottom: 1px solid var(--border); background: var(--bg-elev); }
.tb-titles { min-width: 0; }
.tb-title { font-size: 14px; font-weight: 600; letter-spacing: -0.01em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 52vw; }
.tb-sub { font-size: 11.5px; color: var(--text-dim); }
.tb-spacer { flex: 1; }
.model-btn { display: flex; align-items: center; gap: 8px; padding: 6px 9px 6px 10px; border-radius: var(--radius-sm); border: 1px solid var(--border); background: var(--surface); font-size: 12.5px; font-weight: 500; color: var(--text); transition: border-color .15s; }
.model-btn:hover { border-color: var(--accent-line); }
.model-btn .m-glyph { width: 16px; height: 16px; border-radius: 5px; display: grid; place-items: center; background: var(--accent-soft); color: var(--accent-2); }
.status-pill { display: flex; align-items: center; gap: 7px; font-size: 11.5px; color: var(--text-muted); }
/* ---------------- THREAD ---------------- */
.thread-wrap { flex: 1; overflow-y: auto; min-height: 0; }
.thread { max-width: 820px; margin: 0 auto; padding: 28px 28px 40px; display: flex; flex-direction: column; gap: 26px; }
.msg { display: flex; gap: 14px; }
.msg-avatar { width: 30px; height: 30px; border-radius: 9px; flex-shrink: 0; display: grid; place-items: center; }
.msg-avatar.user { background: linear-gradient(135deg,#5b6cff,#c45bff); color: #fff; font-size: 12px; font-weight: 600; }
.msg-avatar.ai { background: linear-gradient(150deg, var(--accent), var(--accent-2)); color: #fff; }
.msg-body { flex: 1; min-width: 0; padding-top: 3px; }
.msg-name { font-size: 12.5px; font-weight: 600; margin-bottom: 7px; display: flex; align-items: center; gap: 8px; }
.msg-name .ts { font-weight: 400; color: var(--text-dim); font-size: 11px; }
.msg-text { font-size: 14.5px; line-height: 1.62; color: var(--text); }
.msg-text p { margin: 0 0 12px; }
.msg-text p:last-child { margin-bottom: 0; }
.msg-text strong { font-weight: 600; }
.msg-text code { font-family: var(--font-mono); font-size: 12.5px; background: var(--surface-2); padding: 2px 6px; border-radius: 5px; border: 1px solid var(--border); }
.msg-text pre { background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 11px 13px; overflow-x: auto; margin: 10px 0; }
.msg-text pre code { background: none; border: none; padding: 0; }
.msg.user-msg .msg-text { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 12px 15px; }
/* inline chips */
.mention { display: inline-flex; align-items: center; gap: 4px; vertical-align: baseline; font-family: var(--font-mono); font-size: 12.5px; font-weight: 500;
color: var(--accent-2); background: var(--accent-soft); border: 1px solid var(--accent-line); border-radius: var(--radius-sm); padding: 1px 7px 1px 5px; cursor: pointer; transition: filter .13s; }
.mention:hover { filter: brightness(1.18); }
.objlink { display: inline-flex; align-items: center; gap: 5px; cursor: pointer; font-family: var(--font-mono); font-size: 0.92em; font-weight: 500;
color: var(--text); border-bottom: 1px dashed var(--border-strong); padding-bottom: 1px; transition: color .13s, border-color .13s; }
.objlink:hover { color: var(--accent-2); border-color: var(--accent-line); }
.objlink .ol-ico { color: var(--accent-2); display: inline-grid; place-items: center; }
/* ---------------- CONSOLE (agent process) ---------------- */
.console { border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface-trans); overflow: hidden; margin-bottom: 16px; }
.console-head { display: flex; align-items: center; gap: 10px; padding: 10px 13px; cursor: pointer; user-select: none; transition: background .13s; }
.console-head:hover { background: var(--surface-2); }
.console-spin { width: 15px; height: 15px; flex-shrink: 0; border: 2px solid var(--border-strong); border-top-color: var(--accent-2); border-radius: 50%; animation: spin .8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.console-check { width: 15px; height: 15px; flex-shrink: 0; color: var(--ok); display: grid; place-items: center; }
.console-title { font-size: 12.5px; font-weight: 600; flex: 1; display: flex; align-items: center; gap: 8px; }
.console-meta { font-size: 11px; color: var(--text-dim); font-family: var(--font-mono); }
.console-chev { color: var(--text-dim); transition: transform .2s var(--ease); display: grid; place-items: center; }
.console.collapsed .console-chev { transform: rotate(-90deg); }
.console-body { border-top: 1px solid var(--border); display: grid; grid-template-rows: 1fr; transition: grid-template-rows .28s var(--ease); }
.console.collapsed .console-body { grid-template-rows: 0fr; border-top-color: transparent; }
.console-inner { overflow: hidden; padding: 6px 0; }
.step { position: relative; padding: 3px 14px 3px 38px; }
.step::before { content: ''; position: absolute; left: 21px; top: 0; bottom: 0; width: 1.5px; background: var(--border); }
.step:first-child::before { top: 13px; }
.step:last-child::before { bottom: calc(100% - 14px); }
.step-node { position: absolute; left: 14px; top: 8px; width: 15px; height: 15px; border-radius: 50%; background: var(--bg-elev); border: 1.5px solid var(--border-strong); display: grid; place-items: center; z-index: 1; color: var(--text-muted); }
.step.done .step-node { border-color: var(--accent-line); color: var(--accent-2); background: var(--accent-soft); }
.step.run .step-node { border-color: var(--accent); color: var(--accent-2); }
.step.err .step-node { border-color: var(--err); color: var(--err); }
.step-main { padding: 4px 0; }
.step-row { display: flex; align-items: center; gap: 9px; min-height: 24px; }
.step-verb { font-size: 13px; color: var(--text); display: flex; align-items: center; gap: 7px; flex-wrap: wrap; }
.step-verb .v { color: var(--text-muted); }
.step-tool { font-family: var(--font-mono); font-size: 11px; color: var(--text-dim); border: 1px solid var(--border); border-radius: 5px; padding: 1px 6px; }
.step-dur { margin-left: auto; font-size: 10.5px; font-family: var(--font-mono); color: var(--text-dim); }
.step-toggle { font-size: 11px; color: var(--text-dim); display: inline-flex; align-items: center; gap: 4px; cursor: pointer; }
.step-toggle:hover { color: var(--accent-2); }
.think { font-size: 12.5px; line-height: 1.6; color: var(--text-muted); font-style: italic; padding: 8px 0 4px; max-width: 62ch; white-space: pre-wrap; }
.tool-detail { margin: 8px 0 6px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg); overflow: hidden; font-family: var(--font-mono); font-size: 11.5px; line-height: 1.7; }
.td-head { display: flex; align-items: center; gap: 8px; padding: 6px 10px; border-bottom: 1px solid var(--border); color: var(--text-dim); font-size: 10.5px; letter-spacing: .03em; }
.td-body { padding: 8px 12px; overflow-x: auto; white-space: pre-wrap; word-break: break-word; max-height: 320px; overflow-y: auto; color: var(--text-muted); }
.collapsible { display: grid; grid-template-rows: 0fr; transition: grid-template-rows .26s var(--ease); }
.collapsible.open { grid-template-rows: 1fr; }
.collapsible > div { overflow: hidden; }
.streaming-caret { display: inline-block; width: 7px; height: 15px; background: var(--accent-2); margin-left: 2px; vertical-align: text-bottom; animation: blink 1s steps(2) infinite; border-radius: 1px; }
@keyframes blink { 50% { opacity: 0; } }
/* ---------------- COMPOSER ---------------- */
.composer-wrap { flex-shrink: 0; padding: 0 28px 18px; }
.composer-inner { max-width: 820px; margin: 0 auto; position: relative; }
.composer { border: 1px solid var(--border-strong); border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--shadow); transition: border-color .18s, box-shadow .18s; }
.composer.focus { border-color: var(--accent-line); box-shadow: var(--shadow), 0 0 0 3px var(--accent-soft); }
.composer-field { width: 100%; resize: none; border: none; outline: none; background: none; color: var(--text); font-size: 14.5px; line-height: 1.55; padding: 14px 16px 6px; max-height: 200px; min-height: 26px; }
.composer-field::placeholder { color: var(--text-dim); }
.composer-bar { display: flex; align-items: center; gap: 6px; padding: 8px 10px 9px; }
.cb-btn { display: flex; align-items: center; gap: 6px; padding: 6px 9px; border-radius: var(--radius-sm); font-size: 12.5px; color: var(--text-muted); transition: background .13s, color .13s; }
.cb-btn:hover { background: var(--surface-2); color: var(--text); }
.cb-spacer { flex: 1; }
.send-btn { width: 32px; height: 32px; border-radius: var(--radius-sm); display: grid; place-items: center; background: var(--accent); color: #fff; transition: filter .13s, transform .08s, opacity .13s; }
.send-btn:hover { filter: brightness(1.1); }
.send-btn:active { transform: scale(.94); }
.send-btn:disabled { opacity: .4; cursor: not-allowed; }
.composer-hint { text-align: center; font-size: 11px; color: var(--text-dim); margin-top: 9px; }
.composer-hint .kbd { font-size: 10px; }
/* empty state */
.empty-state { flex: 1; display: grid; place-items: center; text-align: center; padding: 40px; }
.empty-state .es-icon { width: 56px; height: 56px; border-radius: 16px; margin: 0 auto 18px; display: grid; place-items: center; background: var(--surface); border: 1px solid var(--border); color: var(--accent-2); }
.empty-state h2 { font-size: 19px; font-weight: 600; margin: 0 0 8px; letter-spacing: -0.01em; }
.empty-state p { font-size: 13.5px; color: var(--text-muted); max-width: 380px; margin: 0 auto; line-height: 1.55; }
.suggest-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 24px; max-width: 460px; }
.suggest { text-align: left; padding: 12px 14px; border-radius: var(--radius); background: var(--surface); border: 1px solid var(--border); transition: border-color .15s, transform .08s; }
.suggest:hover { border-color: var(--accent-line); transform: translateY(-1px); }
.suggest .sg-title { font-size: 12.5px; font-weight: 500; margin-bottom: 3px; }
.suggest .sg-desc { font-size: 11.5px; color: var(--text-dim); line-height: 1.4; }
/* toast */
.toast { position: fixed; bottom: 92px; left: 50%; transform: translateX(-50%) translateY(20px); background: var(--surface-2); border: 1px solid var(--border-strong); color: var(--text); padding: 8px 15px; border-radius: 9px; font-size: 12.5px; z-index: 80; opacity: 0; pointer-events: none; transition: opacity .2s, transform .2s; box-shadow: var(--shadow-pop); }
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
.err-text { color: var(--err); }
/* ---------------- POPOVERS (model menu, mention) ---------------- */
.pop { position: absolute; z-index: 70; min-width: 240px; background: var(--bg-elev); border: 1px solid var(--border-strong); border-radius: var(--radius); box-shadow: var(--shadow-pop); padding: 6px; }
.pop-label { font-size: 10.5px; font-weight: 600; letter-spacing: .05em; text-transform: uppercase; color: var(--text-dim); padding: 8px 9px 4px; }
.pop-item { display: flex; align-items: center; gap: 10px; padding: 8px 9px; border-radius: var(--radius-sm); font-size: 13px; cursor: pointer; }
.pop-item:hover, .pop-item.hl { background: var(--surface-2); }
.pop-item.disabled { cursor: not-allowed; opacity: .58; }
.pop-item.disabled:hover { background: transparent; }
.pop-item .pi-main { flex: 1; min-width: 0; }
.pop-item .pi-name { font-weight: 500; display: flex; align-items: center; gap: 7px; }
.pop-item .pi-desc { font-size: 11px; color: var(--text-dim); margin-top: 1px; }
.pop-item .pi-check { color: var(--accent-2); display: grid; place-items: center; }
/* mention autocomplete */
.mention-pop { left: 12px; bottom: calc(100% + 8px); width: 360px; max-height: 320px; overflow-y: auto; }
.mp-row { display: flex; align-items: center; gap: 10px; padding: 7px 9px; border-radius: var(--radius-sm); cursor: pointer; }
.mp-row:hover, .mp-row.hl { background: var(--surface-2); }
.mp-ico { width: 22px; height: 22px; border-radius: 6px; display: grid; place-items: center; background: var(--accent-soft); color: var(--accent-2); flex-shrink: 0; }
.mp-main { flex: 1; min-width: 0; }
.mp-name { font-size: 13px; font-weight: 500; font-family: var(--font-mono); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.mp-path { font-size: 11px; color: var(--text-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.mp-cls { font-size: 10px; color: var(--text-dim); font-family: var(--font-mono); }
.mp-open { font-size: 9px; font-weight: 600; color: var(--ok); border: 1px solid rgba(78,201,143,.4); border-radius: 4px; padding: 1px 5px; }
.mp-empty { padding: 14px; text-align: center; color: var(--text-dim); font-size: 12.5px; }
/* attachments */
.attach-row { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 8px; }
.attach-thumb { position: relative; width: 56px; height: 56px; border-radius: var(--radius-sm); overflow: hidden; border: 1px solid var(--border-strong); background: var(--surface-2); }
.attach-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
.attach-thumb .at-x { position: absolute; top: 2px; right: 2px; width: 16px; height: 16px; border-radius: 5px; background: rgba(0,0,0,.6); color: #fff; display: grid; place-items: center; }
.attach-thumb .at-x:hover { background: var(--err); }
.msg-images { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 8px; }
.msg-images img { max-width: 220px; max-height: 180px; border-radius: var(--radius-sm); border: 1px solid var(--border); display: block; }
/* ---------------- SETTINGS / INTEGRATIONS ---------------- */
.modal-backdrop { position: fixed; inset: 0; z-index: 100; display: grid; place-items: center; padding: 28px; background: rgba(0,0,0,.72); }
.modal { width: min(820px, 100%); max-height: min(760px, calc(100vh - 56px)); display: flex; flex-direction: column; overflow: hidden; border: 1px solid var(--border-strong); border-radius: var(--radius-lg); background: var(--bg-elev); box-shadow: var(--shadow-pop); }
.sound-modal { width: min(580px, 100%); }
.translate-modal { width: min(440px, 100%); }
.modal-head, .modal-foot { display: flex; align-items: center; gap: 12px; padding: 16px 18px; border-bottom: 1px solid var(--border); }
.modal-foot { justify-content: flex-end; border-top: 1px solid var(--border); border-bottom: none; }
.modal-title { font-size: 16px; font-weight: 650; }
.modal-sub { margin-top: 3px; font-size: 11.5px; color: var(--text-dim); }
.modal-close { margin-left: auto; width: 30px; height: 30px; border-radius: var(--radius-sm); color: var(--text-muted); font-size: 18px; }
.modal-close:hover { background: var(--surface-2); color: var(--text); }
.settings-body { display: grid; grid-template-columns: 170px minmax(0,1fr); min-height: 0; overflow: hidden; }
.settings-nav { padding: 14px 10px; border-right: 1px solid var(--border); background: var(--bg); }
.settings-nav-item { width: 100%; display: flex; align-items: center; gap: 8px; padding: 9px 10px; border-radius: var(--radius-sm); color: var(--text-muted); font-size: 12.5px; text-align: left; }
.settings-nav-item.active { color: var(--text); background: var(--surface-2); }
.settings-content { padding: 22px; overflow-y: auto; }
.settings-kicker { color: var(--accent-2); text-transform: uppercase; letter-spacing: .08em; font-size: 10px; font-weight: 700; }
.settings-content h2 { margin: 5px 0 5px; font-size: 19px; }
.settings-intro { margin: 0 0 18px; color: var(--text-muted); font-size: 12.5px; line-height: 1.55; }
.integration-card { padding: 15px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); margin-top: 12px; }
.integration-head { display: flex; gap: 12px; align-items: center; margin-bottom: 14px; }
.integration-logo { width: 34px; height: 34px; flex-shrink: 0; display: grid; place-items: center; border-radius: 9px; font-size: 11px; font-weight: 700; }
.integration-logo.eleven { color: #050505; background: #f2f1ea; }
.integration-logo.codex { color: #fff; background: linear-gradient(145deg,#353842,#15171b); border: 1px solid var(--border-strong); }
.integration-logo.textfix { color: #fff; background: linear-gradient(145deg,#745fd5,#9b72ee); }
.integration-meta { flex: 1; min-width: 0; }
.integration-meta h3 { margin: 0 0 3px; font-size: 14px; }
.integration-meta p { margin: 0; color: var(--text-dim); font-size: 11.5px; line-height: 1.45; }
.integration-state { flex-shrink: 0; border: 1px solid var(--border); border-radius: 99px; padding: 3px 8px; color: var(--text-dim); font-size: 10.5px; }
.integration-state.connected { color: var(--ok); border-color: rgba(78,201,143,.35); background: rgba(78,201,143,.08); }
.field { display: flex; flex-direction: column; gap: 6px; margin-top: 11px; color: var(--text-muted); font-size: 11.5px; }
.field input, .field select, .field textarea { width: 100%; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); outline: none; padding: 9px 10px; color: var(--text); background: var(--bg); font-size: 12.5px; resize: vertical; }
.field input:focus, .field select:focus, .field textarea:focus { border-color: var(--accent-line); box-shadow: 0 0 0 3px var(--accent-soft); }
.field small, .integration-note { color: var(--text-dim); font-size: 10.5px; }
.field-grid { display: grid; grid-template-columns: minmax(0,1fr) 150px; gap: 12px; }
.integration-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.primary-btn, .secondary-btn { padding: 8px 12px; border-radius: var(--radius-sm); font-size: 12.5px; font-weight: 600; }
.primary-btn { color: #fff; background: var(--accent); }
.primary-btn:hover { filter: brightness(1.1); }
.primary-btn:disabled { opacity: .45; cursor: wait; }
.secondary-btn { color: var(--text-muted); border: 1px solid var(--border-strong); background: var(--surface); }
.secondary-btn:hover { color: var(--text); border-color: var(--accent-line); }
.sound-modal-body { padding: 18px; }
.sound-options { display: grid; grid-template-columns: 110px 120px minmax(0,1fr) 88px; gap: 9px; margin-top: 15px; }
.sound-option { display: flex; min-width: 0; flex-direction: column; gap: 7px; padding: 10px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); }
.sound-option-label { color: var(--text-dim); font-size: 10px; font-weight: 650; letter-spacing: .05em; text-transform: uppercase; }
.sound-option select { min-width: 0; border: none; outline: none; color: var(--text); background: var(--surface); font-size: 12px; }
.toggle-btn { display: flex; align-items: center; gap: 7px; width: fit-content; color: var(--text-muted); font-size: 12px; }
.toggle-track { width: 29px; height: 16px; padding: 2px; border-radius: 99px; background: var(--surface-3); transition: background .16s; }
.toggle-knob { display: block; width: 12px; height: 12px; border-radius: 50%; background: var(--text-muted); transition: transform .16s, background .16s; }
.toggle-btn.on .toggle-track { background: var(--accent); }
.toggle-btn.on .toggle-knob { transform: translateX(13px); background: #fff; }
.range-row { display: flex; align-items: center; gap: 8px; min-width: 0; }
.range-row input { min-width: 0; flex: 1; accent-color: var(--accent-2); }
.range-row strong, .toggle-btn strong { color: var(--text); font-size: 12px; }
@media (max-width: 760px) { .sound-options { grid-template-columns: 1fr 1fr; } }
/* ---------------- GENERATED AUDIO ---------------- */
.audio-set { margin-top: 10px; display: grid; gap: 9px; }
.audio-prompt { margin-bottom: 10px; color: var(--text-muted); font-size: 12.5px; }
.audio-card { padding: 11px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); }
.audio-card-head { display: flex; align-items: center; gap: 9px; margin-bottom: 8px; }
.audio-badge { width: 25px; height: 25px; display: grid; place-items: center; border-radius: 7px; color: var(--accent-2); background: var(--accent-soft); }
.audio-name { flex: 1; min-width: 0; font-family: var(--font-mono); font-size: 11.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.audio-meta { color: var(--text-dim); font-size: 10.5px; }
.forge-player { display: flex; align-items: center; gap: 9px; min-width: 0; padding: 8px 9px; border: 1px solid var(--border); border-radius: 8px; background: var(--bg); }
.forge-player audio { display: none; }
.player-play { width: 27px; height: 27px; flex-shrink: 0; display: grid; place-items: center; border-radius: 50%; color: #fff; background: var(--accent); transition: filter .13s, transform .08s; }
.player-play:hover { filter: brightness(1.15); }
.player-play:active { transform: scale(.93); }
.player-time { flex-shrink: 0; min-width: 30px; color: var(--text-dim); font-family: var(--font-mono); font-size: 10px; text-align: center; }
.player-progress, .player-volume { height: 4px; accent-color: var(--accent-2); cursor: pointer; }
.player-progress { min-width: 70px; flex: 1; }
.player-volume { width: 62px; flex-shrink: 0; }
.player-volume-icon { display: grid; place-items: center; flex-shrink: 0; color: var(--text-dim); }
.player-progress::-webkit-slider-runnable-track, .player-volume::-webkit-slider-runnable-track { height: 4px; border-radius: 99px; background: var(--surface-3); }
.player-progress::-webkit-slider-thumb, .player-volume::-webkit-slider-thumb { width: 11px; height: 11px; margin-top: -3.5px; border-radius: 50%; appearance: none; background: var(--accent-2); box-shadow: 0 0 0 3px var(--accent-soft); }
.import-btn { flex-shrink: 0; padding: 6px 9px; border-radius: 6px; color: var(--accent-2); border: 1px solid var(--accent-line); font-size: 11px; font-weight: 650; }
.import-btn:hover { background: var(--accent-soft); }
.import-btn:disabled { color: var(--text-dim); border-color: var(--border); cursor: wait; }
.import-btn.done { color: var(--ok); border-color: rgba(78,201,143,.35); }
.sound-working { display: flex; align-items: center; gap: 9px; color: var(--text-muted); font-size: 12.5px; }

901
agent-gateway/server.mjs Normal file
View File

@ -0,0 +1,901 @@
// In-engine agent gateway.
//
// What this is:
// A tiny local HTTP server that (1) serves the chat UI loaded by the
// UE WebBrowser widget and (2) runs a Claude agent via the Claude Agent SDK,
// wired to the project's ue-blueprint MCP server so the in-engine chat has
// the exact same UE tools Claude Code uses (edit BPs, spawn nodes, build
// C++, screenshot UMG, ...).
//
// Transport to the browser: POST /api/chat with a JSON body, response is an
// SSE stream of agent events (text deltas, tool start/end, done). The browser
// keeps a sessionId returned on the first turn and replays it to continue the
// conversation (SDK `resume`).
//
// Auth: uses whatever the Agent SDK finds locally (the same subscription
// login Claude Code uses). No API key is read or required here.
import { createServer } from "node:http";
import { readFileSync, existsSync } from "node:fs";
import { readFile, writeFile, mkdir } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { randomUUID } from "node:crypto";
import path from "node:path";
import os from "node:os";
import { spawn } from "node:child_process";
import { query } from "@anthropic-ai/claude-agent-sdk";
import { executePythonInUE } from "../src/ueBridge.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PUBLIC_DIR = path.join(__dirname, "public");
const INTEGRATIONS_FILE = path.join(__dirname, "integrations.json");
const GENERATED_AUDIO_DIR = path.join(__dirname, ".generated-audio");
const AUDIO_CACHE = new Map();
// Optional local auth file (gitignored). Lets you keep the agent's credentials
// out of the global environment. One KEY=VALUE per line. Supported keys:
// CLAUDE_CODE_OAUTH_TOKEN (from `claude setup-token` — subscription auth)
// ANTHROPIC_API_KEY (pay-as-you-go API key)
function loadAuthEnv() {
const file = path.join(__dirname, "auth.env");
if (!existsSync(file)) return;
for (const line of readFileSync(file, "utf8").split(/\r?\n/)) {
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
if (!m) continue;
const val = m[2].replace(/^["']|["']$/g, "");
if (val && !process.env[m[1]]) process.env[m[1]] = val;
}
}
loadAuthEnv();
const HAS_AUTH = !!(process.env.CLAUDE_CODE_OAUTH_TOKEN || process.env.ANTHROPIC_API_KEY);
const PORT = Number(process.env.MCP_AGENT_PORT || 8765);
const HOST = process.env.MCP_AGENT_HOST || "127.0.0.1";
const PROJECT_DIR = (process.env.UE_PROJECT_DIR || path.resolve(__dirname, "..", "..", "..")).replace(/\\/g, "/");
const MCP_SERVER_ENTRY = path.resolve(__dirname, "..", "src", "server.js").replace(/\\/g, "/");
const MODEL = process.env.MCP_AGENT_MODEL || "claude-opus-4-8";
function defaultIntegrations() {
return {
elevenlabs: {
apiKey: "",
importPath: "/Game/Audio/Generated",
variationCount: 3,
loop: false,
durationSeconds: null,
promptInfluence: 0.3,
modelId: "eleven_text_to_sound_v2",
},
textTools: {
improvePrompt: "Fix spelling, grammar, punctuation, and clarity while preserving the original meaning, tone, formatting, and language. Return only the improved text.",
translateLanguages: ["English", "Russian", "German", "French", "Spanish", "Japanese", "Chinese"],
},
};
}
function loadIntegrations() {
try {
const stored = JSON.parse(readFileSync(INTEGRATIONS_FILE, "utf8"));
return {
elevenlabs: {
...defaultIntegrations().elevenlabs,
...(stored.elevenlabs || {}),
},
textTools: {
...defaultIntegrations().textTools,
...(stored.textTools || {}),
},
};
} catch {
return defaultIntegrations();
}
}
let INTEGRATIONS = loadIntegrations();
async function saveIntegrations() {
await writeFile(INTEGRATIONS_FILE, JSON.stringify(INTEGRATIONS, null, 2) + "\n", "utf8");
}
function codexAuthStatus() {
return {
connected: existsSync(path.join(os.homedir(), ".codex", "auth.json")),
method: "OAuth",
};
}
function publicSettings() {
const elevenlabs = INTEGRATIONS.elevenlabs;
return {
elevenlabs: {
configured: !!elevenlabs.apiKey,
apiKeyMasked: elevenlabs.apiKey ? `${elevenlabs.apiKey.slice(0, 5)}...${elevenlabs.apiKey.slice(-4)}` : "",
importPath: elevenlabs.importPath,
variationCount: elevenlabs.variationCount,
loop: elevenlabs.loop,
durationSeconds: elevenlabs.durationSeconds,
promptInfluence: elevenlabs.promptInfluence,
modelId: elevenlabs.modelId,
},
codex: codexAuthStatus(),
textTools: {
improvePrompt: INTEGRATIONS.textTools.improvePrompt,
translateLanguages: INTEGRATIONS.textTools.translateLanguages,
},
};
}
function json(res, status, payload) {
res.writeHead(status, { "Content-Type": "application/json" });
res.end(JSON.stringify(payload));
}
function safeAudioName(prompt, index) {
const base = prompt.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 42) || "generated_sound";
return `${base}_${String(index + 1).padStart(2, "0")}`;
}
function pcm16MonoToWav(pcm, sampleRate = 48000) {
const wav = Buffer.alloc(44 + pcm.length);
wav.write("RIFF", 0);
wav.writeUInt32LE(36 + pcm.length, 4);
wav.write("WAVE", 8);
wav.write("fmt ", 12);
wav.writeUInt32LE(16, 16);
wav.writeUInt16LE(1, 20); // PCM
wav.writeUInt16LE(1, 22); // mono
wav.writeUInt32LE(sampleRate, 24);
wav.writeUInt32LE(sampleRate * 2, 28);
wav.writeUInt16LE(2, 32);
wav.writeUInt16LE(16, 34);
wav.write("data", 36);
wav.writeUInt32LE(pcm.length, 40);
pcm.copy(wav, 44);
return wav;
}
async function generateSoundEffect(prompt, index, options) {
const id = randomUUID();
const name = safeAudioName(prompt, index);
const url = "https://api.elevenlabs.io/v1/sound-generation?output_format=pcm_48000";
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"xi-api-key": INTEGRATIONS.elevenlabs.apiKey,
},
body: JSON.stringify({
text: prompt,
model_id: options.modelId,
loop: options.loop,
duration_seconds: options.durationSeconds,
prompt_influence: options.promptInfluence,
}),
});
if (!response.ok) {
const detail = (await response.text()).slice(0, 500);
throw new Error(`ElevenLabs HTTP ${response.status}: ${detail}`);
}
const bytes = pcm16MonoToWav(Buffer.from(await response.arrayBuffer()));
await mkdir(GENERATED_AUDIO_DIR, { recursive: true });
const filePath = path.join(GENERATED_AUDIO_DIR, `${id}.wav`);
await writeFile(filePath, bytes);
const item = { id, name, prompt, filePath, createdAt: Date.now(), bytes: bytes.length };
AUDIO_CACHE.set(id, item);
return { id, name, prompt, bytes: bytes.length, audioUrl: `/api/elevenlabs/audio/${id}` };
}
async function importGeneratedSound(id) {
const item = AUDIO_CACHE.get(id);
if (!item || !existsSync(item.filePath)) throw new Error("Generated sound has expired. Generate it again.");
const destination = INTEGRATIONS.elevenlabs.importPath;
if (!destination.startsWith("/Game/")) throw new Error("Import path must start with /Game/");
const resultFile = path.join(PROJECT_DIR, "Saved", "MCP", `forge_audio_import_${id}.json`).replace(/\\/g, "/");
const sourceFile = item.filePath.replace(/\\/g, "/");
const assetName = item.name.replace(/[^A-Za-z0-9_]/g, "_");
const py = [
"import unreal, json, os",
`task=unreal.AssetImportTask()`,
`task.set_editor_property('filename', ${JSON.stringify(sourceFile)})`,
`task.set_editor_property('destination_path', ${JSON.stringify(destination)})`,
`task.set_editor_property('destination_name', ${JSON.stringify(assetName)})`,
"task.set_editor_property('automated', True)",
"task.set_editor_property('save', True)",
"task.set_editor_property('replace_existing', False)",
"unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])",
"paths=[str(x) for x in task.get_editor_property('imported_object_paths')]",
`out={'ok':bool(paths),'paths':paths,'error':None if paths else 'UE did not import the WAV file'}`,
`os.makedirs(os.path.dirname(r'${resultFile}'), exist_ok=True)`,
`open(r'${resultFile}','w',encoding='utf-8').write(json.dumps(out))`,
].join("\n");
const sent = await executePythonInUE(py, { timeoutMs: 45000 });
if (!sent.ok) throw new Error(sent.error || "Cannot reach Unreal Editor");
const result = JSON.parse(await readFile(resultFile, "utf8"));
if (!result.ok) throw new Error(result.error || "Import failed");
return result;
}
async function runTextTool(instruction, text) {
if (!HAS_AUTH) throw new Error("Forge agent auth is not configured.");
const prompt = [
"Transform the text exactly as requested.",
"Do not answer the text, discuss it, add notes, or wrap the result in quotes or markdown fences.",
"Return only the transformed text.",
"",
`Instruction: ${instruction}`,
"",
"Text:",
text,
].join("\n");
let result = "";
const options = {
...agentOptions(MODEL),
maxTurns: 1,
allowedTools: [],
mcpServers: {},
systemPrompt: "You are a precise text editing utility. Return only the transformed text.",
};
for await (const message of query({ prompt, options })) {
if (message.type === "result") result = typeof message.result === "string" ? message.result.trim() : result;
}
if (!result) throw new Error("Text tool returned an empty result.");
return result;
}
const SYSTEM_PROMPT = `You are the in-engine assistant for the current Unreal Engine project.
You are embedded inside the Unreal editor via a WebBrowser widget. The developer talks to you here while the editor is open.
You have full access to the project's ue-blueprint MCP tools (prefixed mcp__ue-blueprint__) to inspect and modify the project live: edit Blueprint graphs, spawn/connect nodes, compile, build C++ via live coding, author UMG and screenshot it, run PCG/Niagara/material ops, run console commands, analyze Insights traces, and more. Prefer these tools over guessing.
Conventions:
- Always confirm UE is reachable with mcp__ue-blueprint__ping_ue before tool-heavy work in a fresh session.
- For any visual/UMG work, follow the project's UI workflow and use the screenshot vision loop.
- Reference assets with their full /Game/... or /Script/... path, and Blueprint nodes by name — the chat UI turns those into clickable chips, so be precise.
- Be concise. The chat panel is narrow. Lead with the answer, then detail.
The project lives at ${PROJECT_DIR}.`;
// ---------------------------------------------------------------------------
// SSE helpers
// ---------------------------------------------------------------------------
function sseHead(res) {
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
});
}
function sseSend(res, event, data) {
if (event !== "text") console.log(`[sse] -> ${event}`);
const ok = res.write(`event: ${event}\n`);
res.write(`data: ${JSON.stringify(data)}\n\n`);
if (!ok) console.log(`[sse] backpressure on ${event}`);
}
// ---------------------------------------------------------------------------
// Sessions: one long-lived Agent SDK query per chat, fed via streaming input.
// The conversation lives in memory (no `resume` / disk reload). This avoids the
// "tool_use ids must be unique" 400 that resume+tools produced when the saved
// transcript got a duplicated tool_use block.
// ---------------------------------------------------------------------------
// Models offered in the chat's model switcher. id must be a valid Anthropic id.
const MODELS = [
{ id: "claude-opus-4-8", name: "Claude Opus 4.8", tag: "самый умный" },
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", tag: "баланс" },
{ id: "claude-haiku-4-5-20251001", name: "Claude Haiku 4.5", tag: "быстрый" },
];
const MODEL_IDS = new Set(MODELS.map((m) => m.id));
function modelsForClient() {
const models = [...MODELS];
if (codexAuthStatus().connected) {
models.push({ id: "codex-oauth", name: "Codex", tag: "OAuth connected · runtime unavailable", disabled: true });
}
return models;
}
function agentOptions(model) {
return {
model: MODEL_IDS.has(model) ? model : MODEL,
cwd: PROJECT_DIR,
permissionMode: "bypassPermissions", // no interactive terminal here; full-access agent per project choice
allowDangerouslySkipPermissions: true, // required alongside bypassPermissions
includePartialMessages: true, // live token streaming (safe on SDK 0.3+)
settingSources: ["project"], // load the repo CLAUDE.md so the agent knows the conventions
mcpServers: {
"ue-blueprint": {
type: "stdio",
command: "node",
args: [MCP_SERVER_ENTRY],
env: { ...process.env, UE_PROJECT_DIR: PROJECT_DIR },
},
},
systemPrompt: { type: "preset", preset: "claude_code", append: SYSTEM_PROMPT },
stderr: (data) => console.error("[sdk-stderr]", data),
};
}
// Tools available to the agent — captured from the first init we ever see, so
// the sidebar can show a count before the user's first message.
let CACHED_TOOLS = [];
// Image attachments: uploaded out-of-band (POST /api/upload), then referenced by
// id in the EventSource GET (which can't carry a base64 body).
const ATTACH = new Map();
function putAttach(dataUrl) {
const m = /^data:([^;]+);base64,(.+)$/s.exec(dataUrl || "");
if (!m) return null;
const id = randomUUID().slice(0, 8);
ATTACH.set(id, { media_type: m[1], data: m[2], t: Date.now() });
return id;
}
function takeAttach(ids) {
const out = [];
for (const id of ids) {
const a = ATTACH.get(id);
if (a) { out.push({ media_type: a.media_type, data: a.data }); ATTACH.delete(id); }
}
return out;
}
setInterval(() => { const now = Date.now(); for (const [k, v] of ATTACH) if (now - v.t > 300000) ATTACH.delete(k); }, 60000).unref?.();
const sessions = new Map();
class Session {
constructor(model) {
this.id = randomUUID();
this.model = MODEL_IDS.has(model) ? model : MODEL;
this.pending = []; // queued { text, images? }
this.wake = null; // resolver to wake the input generator
this.res = null; // SSE response for the in-flight turn
this.busy = false; // a turn is running in the SDK (independent of the socket)
this.closed = false;
this.resetTurn();
this.consume();
}
resetTurn() {
this.sawText = false;
this.textLen = 0;
this.assistantText = "";
}
// Streaming input: yields each queued user message, then parks until the next.
async *input() {
while (!this.closed) {
if (this.pending.length === 0) {
await new Promise((r) => { this.wake = r; });
if (this.closed) break;
}
const item = this.pending.shift();
let content;
if (item.images && item.images.length) {
content = [{ type: "text", text: item.text || "" }];
for (const img of item.images) content.push({ type: "image", source: { type: "base64", media_type: img.media_type, data: img.data } });
} else {
content = item.text;
}
yield { type: "user", message: { role: "user", content }, parent_tool_use_id: null, session_id: this.id };
}
}
push(item) {
this.pending.push(item);
if (this.wake) { const w = this.wake; this.wake = null; w(); }
}
beginTurn(res, message, images) {
this.res = res;
this.busy = true;
this.resetTurn();
sseSend(res, "init", { sessionId: this.id, model: this.model, tools: CACHED_TOOLS });
this.push({ text: message, images });
}
endTurn() {
this.busy = false;
if (this.res) { try { this.res.end(); } catch { /* ignore */ } this.res = null; }
}
async consume() {
try {
for await (const msg of query({ prompt: this.input(), options: agentOptions(this.model) })) {
if (msg.type === "system" && msg.subtype === "init" && Array.isArray(msg.tools) && msg.tools.length) CACHED_TOOLS = msg.tools;
this.route(msg);
}
} catch (err) {
console.error("[session]", this.id.slice(0, 8), "error:", err?.message || err);
if (this.res) sseSend(this.res, "error", { message: String(err?.message || err) });
this.endTurn();
}
}
route(msg) {
const res = this.res;
switch (msg.type) {
case "stream_event": {
const ev = msg.event;
if (ev?.type === "content_block_delta" && ev.delta?.type === "text_delta") {
this.sawText = true;
this.textLen += (ev.delta.text || "").length;
if (res) sseSend(res, "text", { delta: ev.delta.text });
} else if (ev?.type === "content_block_delta" && ev.delta?.type === "thinking_delta") {
if (res) sseSend(res, "thinking", { delta: ev.delta.thinking });
}
break;
}
case "assistant": {
for (const b of (msg.message?.content || [])) {
if (b.type === "tool_use") {
if (res) sseSend(res, "tool_start", { id: b.id, name: b.name, input: b.input });
} else if (b.type === "text" && typeof b.text === "string") {
this.assistantText += b.text;
}
}
break;
}
case "user": {
for (const b of (msg.message?.content || [])) {
if (b.type === "tool_result") {
if (res) sseSend(res, "tool_end", {
id: b.tool_use_id,
isError: !!b.is_error,
content: normalizeToolResult(b.content),
});
}
}
break;
}
case "result": {
const resultText = (typeof msg.result === "string" ? msg.result : "") || this.assistantText;
console.log(`[session ${this.id.slice(0, 8)}] result: sawText=${this.sawText} streamed=${this.textLen} resultChars=${resultText.length}`);
if (res) {
if (msg.is_error || msg.subtype === "error_during_execution") {
sseSend(res, "error", { message: resultText || msg.subtype || "agent error" });
} else if (!this.sawText && resultText) {
// No live deltas (tool-only turn) — emit the full final text.
sseSend(res, "text", { delta: resultText });
}
sseSend(res, "done", {
sessionId: this.id,
subtype: msg.subtype,
isError: !!msg.is_error,
durationMs: msg.duration_ms,
costUsd: msg.total_cost_usd,
});
}
this.endTurn();
break;
}
}
}
}
// Bind an SSE response to a (new or existing) session and run one turn.
function handleTurn(res, { message, sessionId, model, images }) {
let session = sessionId ? sessions.get(sessionId) : null;
if (!session) {
session = new Session(model);
sessions.set(session.id, session);
}
sseHead(res);
if (session.busy) {
// A turn is still running in the SDK (client should serialize; this also
// guards against a dropped socket whose turn hasn't finished yet).
sseSend(res, "init", { sessionId: session.id, model: session.model, tools: CACHED_TOOLS });
sseSend(res, "error", { message: "агент ещё занят предыдущим запросом — подожди" });
sseSend(res, "done", { sessionId: session.id, subtype: "busy" });
res.end();
return;
}
res.on("close", () => { if (session.res === res) session.res = null; });
session.beginTurn(res, message, images);
}
// Exact ue-blueprint tool count. The SDK's init reports the MCP server as
// "pending" (tools connect after init), so we ask the MCP server directly over
// its stdio JSON-RPC: initialize → tools/list. Cheap, runs once at startup.
let UE_TOOL_COUNT = 0;
function countMcpTools() {
return new Promise((resolve) => {
let done = false;
const child = spawn("node", [MCP_SERVER_ENTRY], { env: { ...process.env, UE_PROJECT_DIR: PROJECT_DIR } });
const finish = (n) => { if (done) return; done = true; try { child.kill(); } catch { /* ignore */ } resolve(n); };
const send = (o) => { try { child.stdin.write(JSON.stringify(o) + "\n"); } catch { /* ignore */ } };
let buf = "";
child.stdout.on("data", (d) => {
buf += d.toString();
let idx;
while ((idx = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, idx); buf = buf.slice(idx + 1);
if (!line.trim()) continue;
let msg; try { msg = JSON.parse(line); } catch { continue; }
if (msg.id === 1 && msg.result) {
send({ jsonrpc: "2.0", method: "notifications/initialized" });
send({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} });
}
if (msg.id === 2 && msg.result && Array.isArray(msg.result.tools)) finish(msg.result.tools.length);
}
});
child.on("error", () => finish(0));
send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "mcp-agent-gateway", version: "1" } } });
setTimeout(() => finish(0), 8000);
});
}
// Grab the agent's tool list once at startup (init arrives before any model
// call), so the sidebar count is populated before the first chat.
async function primeTools() {
if (!HAS_AUTH || CACHED_TOOLS.length) return;
try {
const q = query({ prompt: "ready", options: agentOptions() });
for await (const m of q) {
if (m.type === "system" && m.subtype === "init") { CACHED_TOOLS = m.tools || []; break; }
}
if (typeof q.interrupt === "function") { try { await q.interrupt(); } catch { /* ignore */ } }
console.log(`[primeTools] cached ${CACHED_TOOLS.length} tools`);
} catch (e) { console.error("[primeTools]", e?.message || e); }
}
// ---------------------------------------------------------------------------
// Asset list (for @mention autocomplete). Cached; refreshed on demand.
// Dumps the AssetRegistry + open editors to a JSON file UE can write and we read.
// ---------------------------------------------------------------------------
let ASSET_CACHE = { at: 0, list: [] };
const ASSET_FILE = path.join(PROJECT_DIR, "Saved", "MCP", "agent_assets.json").replace(/\\/g, "/");
async function refreshAssets() {
const py = [
"import unreal, json, os",
"out=[]; seen=set(); open_paths=set()",
"try:",
" sub=unreal.get_editor_subsystem(unreal.AssetEditorSubsystem)",
" for a in sub.get_all_edited_assets():",
" open_paths.add(a.get_path_name().split('.')[0])",
"except Exception: pass",
"ar=unreal.AssetRegistryHelpers.get_asset_registry()",
"f=unreal.ARFilter(package_paths=['/Game'], recursive_paths=True)",
"for ad in ar.get_assets(f):",
" p=str(ad.package_name)",
" if p in seen: continue",
" seen.add(p)",
" try: cls=str(ad.asset_class_path.asset_name)",
" except Exception: cls=str(getattr(ad,'asset_class',''))",
" out.append({'name':str(ad.asset_name),'path':p,'class':cls,'open':p in open_paths})",
`os.makedirs(os.path.dirname(r'${ASSET_FILE}'), exist_ok=True)`,
`open(r'${ASSET_FILE}','w',encoding='utf-8').write(json.dumps(out))`,
].join("\n");
await executePythonInUE(py, { timeoutMs: 25000 });
const raw = await readFile(ASSET_FILE, "utf8");
ASSET_CACHE = { at: Date.now(), list: JSON.parse(raw) };
}
function readBody(req) {
return new Promise((resolve) => {
let b = ""; req.on("data", (c) => (b += c)); req.on("end", () => { try { resolve(JSON.parse(b || "{}")); } catch { resolve({}); } });
});
}
function normalizeToolResult(content) {
if (typeof content === "string") return content;
if (Array.isArray(content)) {
return content
.map((c) => (c?.type === "text" ? c.text : c?.type ? `[${c.type}]` : String(c)))
.join("\n");
}
return content == null ? "" : String(content);
}
// ---------------------------------------------------------------------------
// Static file serving
// ---------------------------------------------------------------------------
const MIME = {
".html": "text/html; charset=utf-8",
".css": "text/css; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".svg": "image/svg+xml",
".png": "image/png",
".woff2": "font/woff2",
};
async function serveStatic(req, res) {
let rel = decodeURIComponent(req.url.split("?")[0]);
if (rel === "/") rel = "/index.html";
const filePath = path.join(PUBLIC_DIR, path.normalize(rel));
if (!filePath.startsWith(PUBLIC_DIR)) {
res.writeHead(403).end("forbidden");
return;
}
try {
const data = await readFile(filePath);
const ext = path.extname(filePath).toLowerCase();
res.writeHead(200, { "Content-Type": MIME[ext] || "application/octet-stream" });
res.end(data);
} catch {
res.writeHead(404).end("not found");
}
}
// ---------------------------------------------------------------------------
// HTTP server
// ---------------------------------------------------------------------------
const server = createServer(async (req, res) => {
// CORS — WebBrowser widget origins can be quirky; allow all for localhost dev.
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
if (req.method === "OPTIONS") {
res.writeHead(204).end();
return;
}
if (req.method === "POST" && req.url === "/api/chat") {
const payload = await readBody(req);
const message = (payload.message || "").trim();
const images = [];
if (Array.isArray(payload.images)) for (const d of payload.images) { const m = /^data:([^;]+);base64,(.+)$/s.exec(d || ""); if (m) images.push({ media_type: m[1], data: m[2] }); }
if (!message && !images.length) { res.writeHead(400, { "Content-Type": "application/json" }).end('{"error":"empty message"}'); return; }
handleTurn(res, { message, sessionId: payload.sessionId || null, model: payload.model || null, images });
return;
}
// Upload an image attachment (base64 data URL) → returns a short id the
// EventSource GET references via &attach=, since GET can't carry the body.
if (req.method === "POST" && req.url === "/api/upload") {
const payload = await readBody(req);
const id = putAttach(payload.dataUrl);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(id ? { ok: true, id } : { ok: false, error: "bad data url" }));
return;
}
// EventSource transport (GET). UE's CEF WebBrowser does not consume a streamed
// fetch() body, but it reads EventSource reliably. Message rides in the query.
if (req.method === "GET" && req.url.startsWith("/api/chat-stream")) {
const u = new URL(req.url, `http://${req.headers.host}`);
const message = (u.searchParams.get("message") || "").trim();
const sessionId = u.searchParams.get("sessionId") || null;
const model = u.searchParams.get("model") || null;
const images = takeAttach((u.searchParams.get("attach") || "").split(",").filter(Boolean));
if (!message && !images.length) {
res.writeHead(400, { "Content-Type": "application/json" }).end('{"error":"empty message"}');
return;
}
handleTurn(res, { message, sessionId, model, images });
return;
}
// Asset list for @mention autocomplete (cached ~30s).
if (req.method === "GET" && req.url.startsWith("/api/assets")) {
const u = new URL(req.url, `http://${req.headers.host}`);
const qq = (u.searchParams.get("q") || "").toLowerCase();
try { if (Date.now() - ASSET_CACHE.at > 30000) await refreshAssets(); }
catch (e) { console.error("[assets]", e?.message || e); }
let list = ASSET_CACHE.list || [];
if (qq) list = list.filter((a) => a.name.toLowerCase().includes(qq) || a.path.toLowerCase().includes(qq));
list = list.slice().sort((a, b) => (b.open ? 1 : 0) - (a.open ? 1 : 0)).slice(0, 40);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ assets: list, total: (ASSET_CACHE.list || []).length }));
return;
}
if (req.method === "POST" && req.url === "/api/focus") {
let body = "";
req.on("data", (c) => (body += c));
req.on("end", async () => {
let payload = {};
try { payload = JSON.parse(body || "{}"); } catch { /* ignore */ }
const assetPath = (payload.path || "").trim();
res.writeHead(200, { "Content-Type": "application/json" });
if (!assetPath.startsWith("/Game/")) {
res.end(JSON.stringify({ ok: false, error: "not a /Game asset path" }));
return;
}
const py = `import unreal\nunreal.EditorAssetLibrary.sync_browser_to_objects([${JSON.stringify(assetPath)}])`;
const out = await executePythonInUE(py).catch((e) => ({ ok: false, error: String(e) }));
res.end(JSON.stringify(out));
});
return;
}
if (req.method === "GET" && req.url === "/api/settings") {
json(res, 200, publicSettings());
return;
}
if (req.method === "POST" && req.url === "/api/settings") {
const payload = await readBody(req);
const current = INTEGRATIONS.elevenlabs;
const currentTextTools = INTEGRATIONS.textTools;
const importPath = String(payload.elevenlabs?.importPath || current.importPath).trim().replace(/\/+$/, "");
const variationCount = Number(payload.elevenlabs?.variationCount || current.variationCount);
if (!importPath.startsWith("/Game/")) {
json(res, 400, { ok: false, error: "ElevenLabs import path must start with /Game/" });
return;
}
if (!Number.isInteger(variationCount) || variationCount < 1 || variationCount > 4) {
json(res, 400, { ok: false, error: "Variation count must be between 1 and 4" });
return;
}
const nextElevenlabs = {
apiKey: payload.elevenlabs?.apiKey === undefined ? current.apiKey : String(payload.elevenlabs.apiKey).trim(),
importPath,
variationCount,
loop: current.loop,
durationSeconds: payload.elevenlabs?.durationSeconds === null || payload.elevenlabs?.durationSeconds === ""
? null
: Number(payload.elevenlabs?.durationSeconds ?? current.durationSeconds),
promptInfluence: Number(payload.elevenlabs?.promptInfluence ?? current.promptInfluence),
modelId: String(payload.elevenlabs?.modelId || current.modelId),
};
if (nextElevenlabs.durationSeconds !== null && (!Number.isFinite(nextElevenlabs.durationSeconds) || nextElevenlabs.durationSeconds < 0.5 || nextElevenlabs.durationSeconds > 30)) {
json(res, 400, { ok: false, error: "Default ElevenLabs duration must be Auto or between 0.5 and 30 seconds." });
return;
}
if (!Number.isFinite(nextElevenlabs.promptInfluence) || nextElevenlabs.promptInfluence < 0 || nextElevenlabs.promptInfluence > 1) {
json(res, 400, { ok: false, error: "Default ElevenLabs prompt influence must be between 0 and 1." });
return;
}
if (nextElevenlabs.modelId !== "eleven_text_to_sound_v2") {
json(res, 400, { ok: false, error: "Unsupported ElevenLabs Sound Effects model." });
return;
}
INTEGRATIONS.elevenlabs = nextElevenlabs;
const improvePrompt = String(payload.textTools?.improvePrompt || currentTextTools.improvePrompt).trim();
const translateLanguages = Array.isArray(payload.textTools?.translateLanguages)
? payload.textTools.translateLanguages.map((x) => String(x).trim()).filter(Boolean).slice(0, 20)
: currentTextTools.translateLanguages;
if (!improvePrompt || improvePrompt.length > 2000) {
json(res, 400, { ok: false, error: "Text Fix prompt must contain 1 to 2000 characters." });
return;
}
if (!translateLanguages.length) {
json(res, 400, { ok: false, error: "Add at least one translation language." });
return;
}
INTEGRATIONS.textTools = { improvePrompt, translateLanguages };
await saveIntegrations();
json(res, 200, { ok: true, settings: publicSettings() });
return;
}
if (req.method === "POST" && req.url === "/api/text-tools/improve") {
const payload = await readBody(req);
const text = String(payload.text || "").trim();
if (!text || text.length > 12000) {
json(res, 400, { ok: false, error: "Text Fix expects 1 to 12000 characters." });
return;
}
try {
json(res, 200, { ok: true, text: await runTextTool(INTEGRATIONS.textTools.improvePrompt, text) });
} catch (e) {
json(res, 500, { ok: false, error: String(e?.message || e) });
}
return;
}
if (req.method === "POST" && req.url === "/api/text-tools/translate") {
const payload = await readBody(req);
const text = String(payload.text || "").trim();
const language = String(payload.language || "").trim();
if (!text || text.length > 12000) {
json(res, 400, { ok: false, error: "Translate expects 1 to 12000 characters." });
return;
}
if (!INTEGRATIONS.textTools.translateLanguages.includes(language)) {
json(res, 400, { ok: false, error: "Choose a configured translation language." });
return;
}
try {
const instruction = `Translate the text into ${language}. Preserve meaning, tone, paragraph structure, line breaks, asset paths, code, and Unreal identifiers.`;
json(res, 200, { ok: true, text: await runTextTool(instruction, text), language });
} catch (e) {
json(res, 500, { ok: false, error: String(e?.message || e) });
}
return;
}
if (req.method === "POST" && req.url === "/api/integrations/codex/login") {
try {
const child = spawn("cmd.exe", ["/c", "start", "", "codex", "login"], {
detached: true,
stdio: "ignore",
windowsHide: false,
});
child.unref();
json(res, 200, { ok: true, message: "Codex OAuth login opened in a separate terminal." });
} catch (e) {
json(res, 500, { ok: false, error: String(e?.message || e) });
}
return;
}
if (req.method === "POST" && req.url === "/api/elevenlabs/generate") {
const payload = await readBody(req);
const prompt = String(payload.prompt || "").trim();
if (!INTEGRATIONS.elevenlabs.apiKey) {
json(res, 400, { ok: false, error: "Add an ElevenLabs API key in Settings first." });
return;
}
if (!prompt || prompt.length > 450) {
json(res, 400, { ok: false, error: "Sound prompt must contain 1 to 450 characters." });
return;
}
try {
const defaults = INTEGRATIONS.elevenlabs;
const count = Number(payload.variationCount ?? defaults.variationCount);
const loop = payload.loop === undefined ? defaults.loop : !!payload.loop;
const durationSeconds = payload.durationSeconds === null || payload.durationSeconds === undefined || payload.durationSeconds === ""
? null
: Number(payload.durationSeconds);
const promptInfluence = Number(payload.promptInfluence ?? defaults.promptInfluence);
const modelId = String(payload.modelId || defaults.modelId);
if (!Number.isInteger(count) || count < 1 || count > 4) throw new Error("Variation count must be between 1 and 4.");
if (durationSeconds !== null && (!Number.isFinite(durationSeconds) || durationSeconds < 0.5 || durationSeconds > 30)) throw new Error("Duration must be Auto or between 0.5 and 30 seconds.");
if (!Number.isFinite(promptInfluence) || promptInfluence < 0 || promptInfluence > 1) throw new Error("Prompt influence must be between 0 and 1.");
if (modelId !== "eleven_text_to_sound_v2") throw new Error("Unsupported ElevenLabs Sound Effects model.");
const options = { loop, durationSeconds, promptInfluence, modelId };
const sounds = await Promise.all(Array.from({ length: count }, (_, index) => generateSoundEffect(prompt, index, options)));
json(res, 200, { ok: true, prompt, options, sounds });
} catch (e) {
json(res, 502, { ok: false, error: String(e?.message || e) });
}
return;
}
if (req.method === "GET" && req.url.startsWith("/api/elevenlabs/audio/")) {
const id = decodeURIComponent(req.url.split("/").pop().split("?")[0]);
const item = AUDIO_CACHE.get(id);
if (!item || !existsSync(item.filePath)) {
res.writeHead(404).end("audio not found");
return;
}
const bytes = await readFile(item.filePath);
res.writeHead(200, {
"Content-Type": "audio/wav",
"Content-Length": bytes.length,
"Cache-Control": "private, max-age=3600",
});
res.end(bytes);
return;
}
if (req.method === "POST" && req.url === "/api/elevenlabs/import") {
const payload = await readBody(req);
try {
const result = await importGeneratedSound(String(payload.id || ""));
json(res, 200, { ok: true, ...result });
} catch (e) {
json(res, 500, { ok: false, error: String(e?.message || e) });
}
return;
}
if (req.method === "GET" && req.url === "/api/health") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true, model: MODEL, models: modelsForClient(), projectDir: PROJECT_DIR, mcp: MCP_SERVER_ENTRY, hasAuth: HAS_AUTH, tools: CACHED_TOOLS, ueToolCount: UE_TOOL_COUNT, integrations: publicSettings() }));
return;
}
if (req.method === "GET") {
await serveStatic(req, res);
return;
}
res.writeHead(405).end("method not allowed");
});
server.listen(PORT, HOST, () => {
console.log(`[mcp-agent-gateway] http://${HOST}:${PORT}`);
console.log(`[mcp-agent-gateway] model=${MODEL}`);
console.log(`[mcp-agent-gateway] project=${PROJECT_DIR}`);
console.log(`[mcp-agent-gateway] mcp=${MCP_SERVER_ENTRY}`);
if (!HAS_AUTH) {
console.warn("[mcp-agent-gateway] WARNING: no CLAUDE_CODE_OAUTH_TOKEN / ANTHROPIC_API_KEY found.");
console.warn("[mcp-agent-gateway] Run `claude setup-token` and put it in agent-gateway/auth.env — see README.md.");
}
primeTools(); // fire-and-forget: populate the total tool list
countMcpTools().then((n) => { UE_TOOL_COUNT = n; console.log(`[mcp tools] ue-blueprint exposes ${n} tools`); });
});

View File

@ -0,0 +1,14 @@
@echo off
REM Generate a long-lived Claude OAuth token using the Agent SDK's bundled CLI.
REM Opens a browser to log in with your Claude subscription (Pro/Max), then
REM prints a token (sk-ant-oat01-...). Copy it into auth.env as:
REM CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-...
cd /d "%~dp0"
node "node_modules\@anthropic-ai\claude-agent-sdk\cli.js" setup-token
echo.
echo ----------------------------------------------------------------
echo Copy the token above into agent-gateway\auth.env :
echo CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-...
echo Then restart the gateway (node server.mjs).
echo ----------------------------------------------------------------
pause

View File

@ -0,0 +1,10 @@
@echo off
REM Launch the in-engine agent gateway.
cd /d "%~dp0"
if not exist node_modules (
echo [setup] installing dependencies...
call npm install
)
echo [mcp-agent-gateway] starting agent gateway on http://127.0.0.1:8765
node server.mjs
pause

1587
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

18
package.json Normal file
View File

@ -0,0 +1,18 @@
{
"name": "ue-blueprint-mcp",
"version": "0.1.0",
"description": "MCP server for spawning Unreal Engine 5 Blueprint nodes via a universal NodeSpec primitive.",
"type": "module",
"main": "src/server.js",
"bin": {
"ue-blueprint-mcp": "src/server.js"
},
"scripts": {
"start": "node src/server.js",
"test": "node --test \"test/*.test.js\""
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.4",
"better-sqlite3": "^11.5.0"
}
}

226
src/buildOrchestrator.js Normal file
View File

@ -0,0 +1,226 @@
// Build orchestration: save → close editor → run Build.bat → reopen.
//
// Lives outside the editor so it can survive the editor process dying.
// Driven from server.js as the `build_op` MCP tool.
import { spawn } from "node:child_process";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { existsSync } from "node:fs";
import path from "node:path";
import { executePythonInUE, ping } from "./ueBridge.js";
import * as PP from "./projectPaths.js";
const execFileP = promisify(execFile);
// All defaults are auto-detected from where the plugin lives (see projectPaths.js);
// every one can still be overridden per-call (opts) or via environment variable.
function paths(opts = {}) {
const projectDir = opts.project_dir || PP.projectDir();
const engineDir = opts.engine_dir || PP.engineDir();
const uprojectName = opts.uproject_name || PP.uprojectName();
if (!engineDir) {
throw new Error(
"Could not locate an Unreal Engine install. Set UE_ENGINE_DIR (the folder that contains /Engine) " +
"or pass engine_dir in the call."
);
}
if (!uprojectName) {
throw new Error("Could not locate a .uproject. Set UE_PROJECT_DIR / UE_UPROJECT_NAME or pass project_dir.");
}
return {
projectDir,
engineDir,
uproject: path.join(projectDir, uprojectName).replace(/\\/g, "/"),
editorExe: path.join(engineDir, "Engine/Binaries/Win64/UnrealEditor.exe").replace(/\\/g, "/"),
buildBat: path.join(engineDir, "Engine/Build/BatchFiles/Build.bat").replace(/\\/g, "/"),
target: opts.target || PP.buildTarget(),
config: opts.config || process.env.UE_BUILD_CONFIG || "Development",
platform: opts.platform || "Win64",
};
}
// ----- Step 1: save all in editor -----
export async function saveAll() {
const py = [
"import unreal",
"ok_assets = unreal.EditorAssetLibrary.save_directory('/Game', only_if_is_dirty=True, recursive=True)",
"try: unreal.get_editor_subsystem(unreal.LevelEditorSubsystem).save_current_level()",
"except Exception: pass",
"unreal.log('[MCP-BUILD] save_all done assets=' + str(ok_assets))",
].join("\n");
const r = await executePythonInUE(py);
return { ok: r.ok, response: r.response, error: r.error };
}
// ----- Step 2: close editor cleanly -----
export async function quitEditor() {
const py = [
"import unreal",
"try: unreal.SystemLibrary.quit_editor()",
"except Exception as e: unreal.log('[MCP-BUILD] quit_editor failed: ' + str(e))",
].join("\n");
// Fire and forget — the editor will tear down RC mid-call.
try { await executePythonInUE(py, { timeoutMs: 5000 }); } catch { /* expected */ }
return { ok: true };
}
// ----- Helper: wait until UE editor process is gone -----
async function waitEditorGone({ uprojectName, timeoutMs = 90000 }) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
// Check via Remote Control liveness first — fast and authoritative.
const p = await ping({ timeoutMs: 1500 });
if (!p.ok) {
// Confirm with tasklist filtering UnrealEditor.exe with our uproject in cmdline.
const alive = await editorProcessAlive(uprojectName);
if (!alive) return { ok: true, waited_ms: Date.now() - start };
}
await sleep(2000);
}
return { ok: false, error: `editor still alive after ${timeoutMs}ms` };
}
async function editorProcessAlive(uprojectName) {
try {
// WMIC is being deprecated but still on Win10/11. Fallback to powershell.
const { stdout } = await execFileP("powershell.exe", [
"-NoProfile", "-Command",
`Get-CimInstance Win32_Process -Filter "Name='UnrealEditor.exe'" | Select-Object -ExpandProperty CommandLine`,
], { timeout: 5000 });
return stdout.toLowerCase().includes(uprojectName.toLowerCase());
} catch {
return false;
}
}
// ----- Step 3: run Build.bat and parse output -----
export function runBuild(opts = {}) {
return new Promise((resolve) => {
const P = paths(opts);
if (!existsSync(P.buildBat)) {
return resolve({ ok: false, error: `Build.bat not found at ${P.buildBat}` });
}
if (!existsSync(P.uproject)) {
return resolve({ ok: false, error: `.uproject not found at ${P.uproject}` });
}
// Use cmd.exe /c with manually-quoted command — paths with spaces (Program Files)
// get mangled by shell:true otherwise.
const quoted =
`"${P.buildBat}" ${P.target} ${P.platform} ${P.config} ` +
`-Project="${P.uproject}" -WaitMutex -FromMsBuild`;
const started = Date.now();
const child = spawn("cmd.exe", ["/c", quoted], {
windowsVerbatimArguments: true,
shell: false,
cwd: P.engineDir,
});
let stdout = "";
let stderr = "";
const errors = [];
const warnings = [];
const lineHandler = (chunk, isErr) => {
const text = chunk.toString();
if (isErr) stderr += text; else stdout += text;
for (const line of text.split(/\r?\n/)) {
if (!line) continue;
if (/(\s|:)error\s+[A-Z]?\d{2,5}|fatal error|LINK\s*:\s*fatal|:\s*error\s*:/i.test(line)) {
errors.push(line);
} else if (/(\s|:)warning\s+[A-Z]?\d{2,5}|:\s*warning\s*:/i.test(line)) {
warnings.push(line);
}
}
};
child.stdout.on("data", (b) => lineHandler(b, false));
child.stderr.on("data", (b) => lineHandler(b, true));
child.on("error", (e) => {
resolve({ ok: false, error: `spawn failed: ${e.message}`, duration_ms: Date.now() - started });
});
child.on("close", (code) => {
resolve({
ok: code === 0,
exit_code: code,
duration_ms: Date.now() - started,
errors_count: errors.length,
warnings_count: warnings.length,
errors: errors.slice(-30),
warnings: warnings.slice(-15),
stdout_tail: stdout.split(/\r?\n/).slice(-40).join("\n"),
stderr_tail: stderr.split(/\r?\n/).slice(-20).join("\n"),
});
});
});
}
// ----- Step 4: relaunch editor -----
export function launchEditor(opts = {}) {
const P = paths(opts);
if (!existsSync(P.editorExe)) return { ok: false, error: `Editor not found at ${P.editorExe}` };
if (!existsSync(P.uproject)) return { ok: false, error: `.uproject not found at ${P.uproject}` };
const child = spawn(P.editorExe, [P.uproject], { detached: true, stdio: "ignore" });
child.unref();
return { ok: true, pid: child.pid, editor: P.editorExe, project: P.uproject };
}
// ----- Orchestration: full save → close → build → reopen -----
export async function fullRebuild(opts = {}) {
const log = [];
const P = paths(opts);
const autoReopen = opts.auto_reopen !== false;
const skipSave = !!opts.skip_save;
const skipClose = !!opts.skip_close;
// 1. Save
if (!skipSave) {
const s = await saveAll();
log.push({ step: "save_all", ok: s.ok, error: s.error });
if (!s.ok) return { ok: false, where: "save_all", log };
}
// 2. Close
if (!skipClose) {
const live = await ping({ timeoutMs: 1500 });
if (live.ok) {
await quitEditor();
const wait = await waitEditorGone({ uprojectName: P.uproject.split("/").pop(), timeoutMs: 90000 });
log.push({ step: "quit_editor", ok: wait.ok, waited_ms: wait.waited_ms, error: wait.error });
if (!wait.ok) return { ok: false, where: "quit_editor", log };
} else {
log.push({ step: "quit_editor", ok: true, note: "editor was already down" });
}
}
// 3. Build
const b = await runBuild(opts);
log.push({ step: "build", ok: b.ok, duration_ms: b.duration_ms,
errors_count: b.errors_count, warnings_count: b.warnings_count, exit_code: b.exit_code });
if (!b.ok) {
return {
ok: false, where: "build", log,
build: {
errors: b.errors, warnings: b.warnings,
stdout_tail: b.stdout_tail, stderr_tail: b.stderr_tail,
duration_ms: b.duration_ms, exit_code: b.exit_code,
},
};
}
// 4. Reopen
if (autoReopen) {
const l = launchEditor(opts);
log.push({ step: "launch_editor", ok: l.ok, pid: l.pid, error: l.error });
}
return {
ok: true, log,
build: { duration_ms: b.duration_ms, warnings_count: b.warnings_count, warnings: b.warnings },
};
}
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }

751
src/insights.js Normal file
View File

@ -0,0 +1,751 @@
// MCP tool handlers for Unreal Insights analytics.
//
// Public surface (called from server.js):
// handleOpenTrace, handleListTraces, handleSummary,
// handleFrameStats, handleTopScopes, handleScopeDetail,
// handleGpuTop, handleMemoryOverview, handleLoadingTop,
// handleCompareTraces, handleQuery, handleReport
//
// Storage layout (per project):
// <project>/Saved/InsightsMCP/
// traces.json — index { trace_id: {path, label, ...} }
// <trace_id>/
// manifest.json
// raw/*.tsv — Insights export
// cache.sqlite — parsed
// insights.log
import fs from "node:fs";
import fsp from "node:fs/promises";
import path from "node:path";
import { exportTrace, findInsightsExe, hashTrace } from "./insightsExporter.js";
import {
openDb, ingestRawDir, listTables, tableColumns, dbAvailable,
} from "./insightsDb.js";
import {
frameTimeline, frameHistogram, topScopesBar, threadBreakdown, buildDashboard,
} from "./insightsChart.js";
import { projectDir as detectProjectDir } from "./projectPaths.js";
const DEFAULT_PROJECT_DIR = detectProjectDir();
function cacheRoot(projectDir = DEFAULT_PROJECT_DIR) {
return path.join(projectDir, "Saved/InsightsMCP");
}
function traceDir(traceId, projectDir) {
return path.join(cacheRoot(projectDir), traceId);
}
function ok(obj) { return { content: [{ type: "text", text: JSON.stringify(obj, null, 2) }] }; }
function err(msg) { return { content: [{ type: "text", text: msg }], isError: true }; }
// ---------- traces index ----------
function readIndex(projectDir) {
const p = path.join(cacheRoot(projectDir), "traces.json");
if (!fs.existsSync(p)) return {};
try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch { return {}; }
}
function writeIndex(projectDir, idx) {
const root = cacheRoot(projectDir);
fs.mkdirSync(root, { recursive: true });
fs.writeFileSync(path.join(root, "traces.json"), JSON.stringify(idx, null, 2));
}
function resolveTrace(traceIdOrLabel, projectDir) {
const idx = readIndex(projectDir);
if (idx[traceIdOrLabel]) return { id: traceIdOrLabel, ...idx[traceIdOrLabel] };
for (const [id, meta] of Object.entries(idx)) {
if (meta.label === traceIdOrLabel) return { id, ...meta };
}
return null;
}
// ---------- column inference ----------
function pickColumn(cols, candidates) {
const names = cols.map((c) => c.name);
for (const cand of candidates) {
const hit = names.find((n) => n.toLowerCase() === cand.toLowerCase());
if (hit) return hit;
}
for (const cand of candidates) {
const hit = names.find((n) => n.toLowerCase().includes(cand.toLowerCase()));
if (hit) return hit;
}
return null;
}
function findTable(db, candidates) {
const tables = listTables(db);
for (const cand of candidates) {
const hit = tables.find((t) => t.toLowerCase() === cand.toLowerCase());
if (hit) return hit;
}
for (const cand of candidates) {
const hit = tables.find((t) => t.toLowerCase().includes(cand.toLowerCase()));
if (hit) return hit;
}
return null;
}
// ---------- tool handlers ----------
export async function handleOpenTrace({ trace_path, label, force, project_dir }) {
if (!trace_path) return err("open_trace: trace_path required");
if (!dbAvailable()) return err("better-sqlite3 not installed. Run `npm install` in Plugins/UEBlueprintMCP.");
const projectDir = project_dir || DEFAULT_PROJECT_DIR;
let absPath = trace_path.replace(/\\/g, "/");
if (!fs.existsSync(absPath)) {
const inProject = path.join(projectDir, "Saved/Profiling/UnrealInsights", path.basename(absPath));
if (fs.existsSync(inProject)) absPath = inProject;
else return err(`trace file not found: ${trace_path}`);
}
const exe = findInsightsExe();
if (!exe) return err("UnrealInsights.exe not found. Set env UE_INSIGHTS_EXE to override.");
const hash = await hashTrace(absPath);
const dir = traceDir(hash, projectDir);
const sqlitePath = path.join(dir, "cache.sqlite");
const alreadyCached = fs.existsSync(sqlitePath) && !force;
let exportResult = null;
let ingestSummary = null;
if (!alreadyCached) {
fs.mkdirSync(dir, { recursive: true });
exportResult = await exportTrace({ tracePath: absPath, cacheDir: dir });
if (!exportResult.ok) {
return err(`export failed: ${exportResult.error}\nlog: ${exportResult.logPath}\nstderr tail:\n${exportResult.stderr}`);
}
const db = openDb(dir);
try {
ingestSummary = await ingestRawDir(db, exportResult.rawDir);
} finally {
db.close();
}
}
// Build summary by probing tables.
const summary = computeSummary(dir);
const idx = readIndex(projectDir);
idx[hash] = {
label: label || path.basename(absPath),
trace_path: absPath,
insights_exe: exe,
opened_at: new Date().toISOString(),
size_bytes: (await fsp.stat(absPath)).size,
};
writeIndex(projectDir, idx);
fs.writeFileSync(path.join(dir, "manifest.json"), JSON.stringify({
trace_id: hash, trace_path: absPath, label: idx[hash].label,
insights_exe: exe,
export: exportResult,
ingest: ingestSummary,
summary,
}, null, 2));
return ok({
trace_id: hash,
label: idx[hash].label,
cache_dir: dir,
cached: alreadyCached,
summary,
});
}
export async function handleListTraces({ project_dir }) {
const projectDir = project_dir || DEFAULT_PROJECT_DIR;
const idx = readIndex(projectDir);
const out = [];
for (const [id, meta] of Object.entries(idx)) {
out.push({ trace_id: id, ...meta, exists: fs.existsSync(traceDir(id, projectDir)) });
}
return ok({ project_dir: projectDir, count: out.length, traces: out });
}
export async function handleSummary({ trace_id, project_dir }) {
const projectDir = project_dir || DEFAULT_PROJECT_DIR;
const t = resolveTrace(trace_id, projectDir);
if (!t) return err(`trace not found: ${trace_id}`);
return ok({ trace_id: t.id, label: t.label, ...computeSummary(traceDir(t.id, projectDir)) });
}
function computeSummary(dir) {
const db = openDb(dir);
try {
const tables = listTables(db);
const byTable = {};
for (const t of tables) {
const cols = tableColumns(db, t);
const count = db.prepare(`SELECT COUNT(*) AS n FROM "${t}"`).get().n;
byTable[t] = { rows: count, columns: cols.map((c) => c.name) };
}
// Try to infer time span from frame_markers, then any events-shaped table.
let frames = null;
const candidates = ["frame_markers", "frames", "events", "timing_events", "gpu_events"];
for (const c of candidates) {
const tbl = findTable(db, [c]);
if (!tbl) continue;
const cols = tableColumns(db, tbl);
const start = pickColumn(cols, ["StartTime", "Time"]);
if (!start) continue;
const r = db.prepare(`SELECT MIN("${start}") AS t0, MAX("${start}") AS t1, COUNT(*) AS n FROM "${tbl}"`).get();
if (r && r.t0 != null && r.t1 != null) {
frames = { source: tbl, trace_start_s: r.t0, trace_end_s: r.t1, duration_s: r.t1 - r.t0, count: r.n };
break;
}
}
return { tables: byTable, time: frames };
} finally {
db.close();
}
}
// ---------- frame stats ----------
export async function handleFrameStats({ trace_id, hitch_ms = 33, top_n = 20, project_dir }) {
const projectDir = project_dir || DEFAULT_PROJECT_DIR;
const t = resolveTrace(trace_id, projectDir);
if (!t) return err(`trace not found: ${trace_id}`);
const db = openDb(traceDir(t.id, projectDir));
try {
// Prefer dedicated frame_markers table (virtual "Game Frames" thread).
// Fall back to filtering events tables by frame-ish names.
const fm = findTable(db, ["frame_markers", "frames", "events", "timing_events"]);
if (!fm) return err("no frame_markers/events table — TimingInsights.ExportTimingEvents didn't run");
const cols = tableColumns(db, fm);
const dur = pickColumn(cols, ["Duration", "InclusiveDuration", "ExclusiveDuration"]);
const start = pickColumn(cols, ["StartTime", "Time"]);
const name = pickColumn(cols, ["TimerName", "Name", "EventName"]);
const thread = pickColumn(cols, ["ThreadName", "Thread"]);
if (!dur || !start) return err(`couldn't find Duration/StartTime in ${fm}: ${cols.map(c => c.name).join(",")}`);
let frames;
if (fm === "frame_markers") {
// "Game Frames" virtual thread emits one event per game frame; in
// practice they all sit at Depth=1 (not 0). Filter by thread only.
const where = [];
if (thread) where.push(`LOWER("${thread}") LIKE '%game%'`);
const sql = `SELECT "${start}" AS start_s, "${dur}"*1000 AS ms FROM "${fm}"`
+ (where.length ? ` WHERE ${where.join(" AND ")}` : "")
+ ` ORDER BY "${start}"`;
frames = db.prepare(sql).all();
} else {
const frameFilter = name && thread
? `WHERE LOWER("${name}") LIKE '%frame%' AND LOWER("${thread}") LIKE '%game%'`
: "";
frames = db.prepare(
`SELECT "${start}" AS start_s, "${dur}"*1000 AS ms FROM "${fm}" ${frameFilter} ORDER BY "${start}"`
).all();
}
if (frames.length === 0) return err("no frames detected — check that 'frame' channel was traced");
const ms = frames.map((f) => f.ms);
ms.sort((a, b) => a - b);
const pct = (p) => ms[Math.min(ms.length - 1, Math.floor(p / 100 * ms.length))];
const avg = ms.reduce((a, b) => a + b, 0) / ms.length;
const hitches = frames.filter((f) => f.ms >= hitch_ms)
.sort((a, b) => b.ms - a.ms)
.slice(0, top_n)
.map((f) => ({ start_s: f.start_s, ms: Number(f.ms.toFixed(2)) }));
return ok({
trace_id: t.id,
frames: frames.length,
avg_ms: Number(avg.toFixed(2)),
avg_fps: Number((1000 / avg).toFixed(1)),
p50_ms: Number(pct(50).toFixed(2)),
p95_ms: Number(pct(95).toFixed(2)),
p99_ms: Number(pct(99).toFixed(2)),
max_ms: Number(pct(100).toFixed(2)),
hitch_threshold_ms: hitch_ms,
hitch_count: frames.filter((f) => f.ms >= hitch_ms).length,
worst_hitches: hitches,
});
} finally {
db.close();
}
}
// ---------- top scopes ----------
export async function handleTopScopes({ trace_id, thread, limit = 20, sort_by = "incl", project_dir }) {
const projectDir = project_dir || DEFAULT_PROJECT_DIR;
const t = resolveTrace(trace_id, projectDir);
if (!t) return err(`trace not found: ${trace_id}`);
const db = openDb(traceDir(t.id, projectDir));
try {
// Per-thread tables let us answer "what's heavy on GameThread" without
// re-aggregating events. Map common aliases to actual table names.
const threadMap = {
"game": "timer_stats_game", "gamethread": "timer_stats_game",
"render": "timer_stats_render", "renderthread":"timer_stats_render",
"rhi": "timer_stats_rhi",
"gpu": "timer_stats_gpu",
};
const tables = listTables(db);
let stats = null;
if (thread) {
const key = thread.toLowerCase().replace(/\s+/g, "");
const want = threadMap[key];
if (want && tables.includes(want)) stats = want;
}
if (!stats) {
stats = findTable(db, ["timer_stats_all", "timer_stats", "timerstats"]);
}
if (stats) {
const cols = tableColumns(db, stats);
// UE 5.8 ExportTimerStatistics columns: Name, Count, C.Avg, Incl, I.Min, I.Max, I.Avg, I.Med, Excl, E.Min, E.Max, E.Avg, E.Med
const nameCol = pickColumn(cols, ["Name", "TimerName"]);
const inclCol = pickColumn(cols, ["Incl", "TotalInclusive", "InclusiveTime"]);
const exclCol = pickColumn(cols, ["Excl", "TotalExclusive", "ExclusiveTime"]);
const countCol = pickColumn(cols, ["Count", "Calls"]);
const avgIncl = pickColumn(cols, ["I.Avg", "I_Avg", "AvgInclusive"]);
const maxIncl = pickColumn(cols, ["I.Max", "I_Max", "MaxInclusive"]);
const sortCol = sort_by === "excl" ? exclCol : inclCol;
if (nameCol && sortCol) {
const parts = [`"${nameCol}" AS name`];
if (inclCol) parts.push(`"${inclCol}" AS incl_s`);
if (exclCol) parts.push(`"${exclCol}" AS excl_s`);
if (avgIncl) parts.push(`"${avgIncl}" AS avg_incl_s`);
if (maxIncl) parts.push(`"${maxIncl}" AS max_incl_s`);
if (countCol) parts.push(`"${countCol}" AS calls`);
const rows = db.prepare(
`SELECT ${parts.join(", ")} FROM "${stats}" ORDER BY "${sortCol}" DESC LIMIT ?`
).all(limit);
return ok({
trace_id: t.id, source: stats, thread_scope: thread || "all", sorted_by: sort_by,
rows: rows.map(formatScopeRow),
});
}
}
// Fallback: aggregate from any events table.
const events = findTable(db, ["frame_markers", "gpu_events", "events", "timing_events"]);
if (!events) return err("no timer_stats_* or events tables available");
const cols = tableColumns(db, events);
const nameCol = pickColumn(cols, ["TimerName", "Name"]);
const durCol = pickColumn(cols, ["Duration", "InclusiveDuration"]);
const threadCol = pickColumn(cols, ["ThreadName", "Thread"]);
if (!nameCol || !durCol) return err(`events table missing Name/Duration: ${cols.map(c => c.name).join(",")}`);
const where = thread && threadCol ? `WHERE LOWER("${threadCol}") LIKE ?` : "";
const args = thread && threadCol ? [`%${thread.toLowerCase()}%`] : [];
const rows = db.prepare(
`SELECT "${nameCol}" AS name, COUNT(*) AS calls, SUM("${durCol}") AS incl_s, AVG("${durCol}") AS avg_incl_s, MAX("${durCol}") AS max_incl_s ` +
`FROM "${events}" ${where} GROUP BY "${nameCol}" ORDER BY incl_s DESC LIMIT ?`
).all(...args, limit);
return ok({
trace_id: t.id, source: events, sorted_by: "incl (aggregated from events)",
rows: rows.map(formatScopeRow),
});
} finally {
db.close();
}
}
function formatScopeRow(r) {
const out = { ...r };
for (const k of ["incl_s", "excl_s", "avg_s", "max_s", "avg_incl_s", "max_incl_s", "min_incl_s"]) {
if (out[k] != null) {
out[k.replace("_s", "_ms")] = Number((out[k] * 1000).toFixed(3));
delete out[k];
}
}
return out;
}
// ---------- scope detail ----------
export async function handleScopeDetail({ trace_id, name, project_dir }) {
if (!name) return err("scope_detail: name required");
const projectDir = project_dir || DEFAULT_PROJECT_DIR;
const t = resolveTrace(trace_id, projectDir);
if (!t) return err(`trace not found: ${trace_id}`);
const db = openDb(traceDir(t.id, projectDir));
try {
const events = findTable(db, ["events", "timing_events"]);
if (!events) return err("no events table");
const cols = tableColumns(db, events);
const nameCol = pickColumn(cols, ["Name", "TimerName"]);
const durCol = pickColumn(cols, ["Duration", "InclusiveDuration"]);
const startCol = pickColumn(cols, ["StartTime", "Time"]);
const threadCol = pickColumn(cols, ["ThreadName", "Thread"]);
if (!nameCol || !durCol) return err("events table missing Name/Duration");
const calls = db.prepare(
`SELECT COUNT(*) AS n, SUM("${durCol}") AS sum_s, AVG("${durCol}") AS avg_s, MAX("${durCol}") AS max_s, MIN("${durCol}") AS min_s FROM "${events}" WHERE "${nameCol}" = ?`
).get(name);
if (!calls || calls.n === 0) return err(`scope not found: ${name}`);
const worst = db.prepare(
`SELECT "${startCol || "rowid"}" AS start, "${durCol}"*1000 AS ms${threadCol ? `, "${threadCol}" AS thread` : ""} FROM "${events}" WHERE "${nameCol}" = ? ORDER BY "${durCol}" DESC LIMIT 10`
).all(name);
return ok({
trace_id: t.id,
scope: name,
calls: calls.n,
total_ms: Number((calls.sum_s * 1000).toFixed(2)),
avg_ms: Number((calls.avg_s * 1000).toFixed(3)),
min_ms: Number((calls.min_s * 1000).toFixed(3)),
max_ms: Number((calls.max_s * 1000).toFixed(3)),
worst_instances: worst.map((w) => ({ ...w, ms: Number(w.ms.toFixed(3)) })),
});
} finally {
db.close();
}
}
// ---------- gpu top ----------
export async function handleGpuTop({ trace_id, limit = 20, project_dir }) {
const projectDir = project_dir || DEFAULT_PROJECT_DIR;
const t = resolveTrace(trace_id, projectDir);
if (!t) return err(`trace not found: ${trace_id}`);
const db = openDb(traceDir(t.id, projectDir));
try {
// Prefer the per-GPU aggregated stats table (small, pre-sorted).
const stats = findTable(db, ["timer_stats_gpu"]);
if (stats) {
const cols = tableColumns(db, stats);
const nameCol = pickColumn(cols, ["Name"]);
const inclCol = pickColumn(cols, ["Incl"]);
const countCol = pickColumn(cols, ["Count"]);
const avgIncl = pickColumn(cols, ["I.Avg"]);
const maxIncl = pickColumn(cols, ["I.Max"]);
if (nameCol && inclCol) {
const rows = db.prepare(
`SELECT "${nameCol}" AS name, "${inclCol}" AS incl_s` +
(countCol ? `, "${countCol}" AS calls` : "") +
(avgIncl ? `, "${avgIncl}" AS avg_incl_s` : "") +
(maxIncl ? `, "${maxIncl}" AS max_incl_s` : "") +
` FROM "${stats}" ORDER BY "${inclCol}" DESC LIMIT ?`
).all(limit);
return ok({ trace_id: t.id, source: stats, rows: rows.map(formatScopeRow) });
}
}
// Fallback: aggregate gpu_events.
const gev = findTable(db, ["gpu_events"]);
if (!gev) return ok({ trace_id: t.id, note: "no GPU data — was the 'gpu' channel enabled when recording?" });
const cols = tableColumns(db, gev);
const nameCol = pickColumn(cols, ["TimerName", "Name"]);
const durCol = pickColumn(cols, ["Duration"]);
if (!nameCol || !durCol) return err("gpu_events table missing Name/Duration");
const rows = db.prepare(
`SELECT "${nameCol}" AS name, COUNT(*) AS calls, SUM("${durCol}") AS incl_s, AVG("${durCol}") AS avg_incl_s, MAX("${durCol}") AS max_incl_s ` +
`FROM "${gev}" GROUP BY "${nameCol}" ORDER BY incl_s DESC LIMIT ?`
).all(limit);
return ok({ trace_id: t.id, source: gev, rows: rows.map(formatScopeRow) });
} finally {
db.close();
}
}
// ---------- memory ----------
export async function handleMemoryOverview({ trace_id, project_dir }) {
const projectDir = project_dir || DEFAULT_PROJECT_DIR;
const t = resolveTrace(trace_id, projectDir);
if (!t) return err(`trace not found: ${trace_id}`);
const db = openDb(traceDir(t.id, projectDir));
try {
const memtags = findTable(db, ["memtags", "llm_tags"]);
if (!memtags) return ok({ trace_id: t.id, note: "no memtags table — record with -trace=memory" });
const cols = tableColumns(db, memtags);
const peakCol = pickColumn(cols, ["Peak", "PeakBytes", "PeakMB"]);
const curCol = pickColumn(cols, ["Current", "CurrentBytes", "CurrentMB", "Size"]);
const nameCol = pickColumn(cols, ["Name", "Tag", "TagName"]);
if (!nameCol) return err("memtags table missing Name");
const orderBy = peakCol || curCol;
if (!orderBy) {
const all = db.prepare(`SELECT * FROM "${memtags}" LIMIT 200`).all();
return ok({ trace_id: t.id, note: "no numeric size column detected — returning raw rows", rows: all });
}
const rows = db.prepare(`SELECT * FROM "${memtags}" ORDER BY "${orderBy}" DESC LIMIT 50`).all();
return ok({ trace_id: t.id, sorted_by: orderBy, rows });
} finally {
db.close();
}
}
// ---------- loading ----------
export async function handleLoadingTop({ trace_id, limit = 30, project_dir }) {
const projectDir = project_dir || DEFAULT_PROJECT_DIR;
const t = resolveTrace(trace_id, projectDir);
if (!t) return err(`trace not found: ${trace_id}`);
const db = openDb(traceDir(t.id, projectDir));
try {
const tbl = findTable(db, ["loadtime_events", "loadtime", "loadtime_summary"]);
if (!tbl) return ok({ trace_id: t.id, note: "no loadtime table — record with -trace=loadtime,asset" });
const cols = tableColumns(db, tbl);
const nameCol = pickColumn(cols, ["Name", "Package", "Asset", "AssetName", "PackageName"]);
const durCol = pickColumn(cols, ["Duration", "LoadTime", "InclusiveTime", "TotalDuration"]);
if (!nameCol || !durCol) {
const sample = db.prepare(`SELECT * FROM "${tbl}" LIMIT ${limit}`).all();
return ok({ trace_id: t.id, note: "couldn't pick Name/Duration; raw sample", columns: cols.map(c => c.name), rows: sample });
}
const rows = db.prepare(
`SELECT "${nameCol}" AS name, "${durCol}" AS s FROM "${tbl}" ORDER BY "${durCol}" DESC LIMIT ?`
).all(limit);
return ok({
trace_id: t.id, source: tbl,
rows: rows.map((r) => ({ name: r.name, ms: Number((r.s * 1000).toFixed(2)) })),
});
} finally {
db.close();
}
}
// ---------- compare ----------
export async function handleCompareTraces({ trace_a, trace_b, project_dir }) {
if (!trace_a || !trace_b) return err("compare_traces: trace_a and trace_b required");
const projectDir = project_dir || DEFAULT_PROJECT_DIR;
const A = await handleFrameStats({ trace_id: trace_a, project_dir });
const B = await handleFrameStats({ trace_id: trace_b, project_dir });
if (A.isError) return A;
if (B.isError) return B;
const a = JSON.parse(A.content[0].text);
const b = JSON.parse(B.content[0].text);
const dt = (k) => ({ a: a[k], b: b[k], delta: Number((b[k] - a[k]).toFixed(3)), pct: a[k] ? Number((100 * (b[k] - a[k]) / a[k]).toFixed(1)) : null });
// Top scopes diff
const scopesA = JSON.parse((await handleTopScopes({ trace_id: trace_a, limit: 50, project_dir })).content[0].text).rows || [];
const scopesB = JSON.parse((await handleTopScopes({ trace_id: trace_b, limit: 50, project_dir })).content[0].text).rows || [];
const mapA = new Map(scopesA.map((r) => [r.name, r.incl_ms ?? r.excl_ms ?? 0]));
const mapB = new Map(scopesB.map((r) => [r.name, r.incl_ms ?? r.excl_ms ?? 0]));
const allNames = new Set([...mapA.keys(), ...mapB.keys()]);
const scopeDiff = [];
for (const n of allNames) {
const va = mapA.get(n) ?? 0;
const vb = mapB.get(n) ?? 0;
if (va === 0 && vb === 0) continue;
scopeDiff.push({ name: n, a_ms: va, b_ms: vb, delta_ms: Number((vb - va).toFixed(2)) });
}
scopeDiff.sort((x, y) => Math.abs(y.delta_ms) - Math.abs(x.delta_ms));
return ok({
a: trace_a, b: trace_b,
frame_stats: {
avg_ms: dt("avg_ms"), p95_ms: dt("p95_ms"), p99_ms: dt("p99_ms"),
max_ms: dt("max_ms"), hitch_count: dt("hitch_count"),
},
top_changed_scopes: scopeDiff.slice(0, 20),
});
}
// ---------- raw SQL ----------
export async function handleQuery({ trace_id, sql, project_dir }) {
if (!sql) return err("query: sql required");
if (/\b(INSERT|UPDATE|DELETE|DROP|ALTER|CREATE|ATTACH|PRAGMA)\b/i.test(sql)) {
return err("query: read-only SELECT statements only");
}
const projectDir = project_dir || DEFAULT_PROJECT_DIR;
const t = resolveTrace(trace_id, projectDir);
if (!t) return err(`trace not found: ${trace_id}`);
const db = openDb(traceDir(t.id, projectDir));
try {
const rows = db.prepare(sql).all();
return ok({ trace_id: t.id, rows: rows.slice(0, 1000), row_count: rows.length });
} catch (e) {
return err(`SQL error: ${e.message}`);
} finally {
db.close();
}
}
// ---------- report ----------
export async function handleReport({ trace_id, save = true, project_dir }) {
const projectDir = project_dir || DEFAULT_PROJECT_DIR;
const t = resolveTrace(trace_id, projectDir);
if (!t) return err(`trace not found: ${trace_id}`);
const frames = parseOr(await handleFrameStats({ trace_id, project_dir }));
const scopes = parseOr(await handleTopScopes({ trace_id, limit: 15, project_dir }));
const gpu = parseOr(await handleGpuTop({ trace_id, limit: 10, project_dir }));
const mem = parseOr(await handleMemoryOverview({ trace_id, project_dir }));
const load = parseOr(await handleLoadingTop({ trace_id, limit: 15, project_dir }));
const lines = [];
lines.push(`# Insights report — ${t.label}`);
lines.push(`Trace: \`${t.trace_path}\``);
lines.push("");
if (frames && !frames.error) {
lines.push(`## Frames`);
lines.push(`- ${frames.frames} frames, avg ${frames.avg_ms}ms (${frames.avg_fps} fps)`);
lines.push(`- p95: ${frames.p95_ms}ms, p99: ${frames.p99_ms}ms, max: ${frames.max_ms}ms`);
lines.push(`- hitches >${frames.hitch_threshold_ms}ms: ${frames.hitch_count}`);
if (frames.worst_hitches?.length) {
lines.push(`### Worst hitches`);
for (const h of frames.worst_hitches.slice(0, 5)) {
lines.push(`- t=${h.start_s.toFixed(2)}s — ${h.ms}ms`);
}
}
lines.push("");
}
if (scopes?.rows?.length) {
lines.push(`## Top scopes (inclusive)`);
for (const r of scopes.rows.slice(0, 10)) {
lines.push(`- ${r.name}${r.incl_ms ?? r.excl_ms ?? "?"}ms (calls: ${r.calls ?? "?"})`);
}
lines.push("");
}
if (gpu?.rows?.length) {
lines.push(`## GPU top`);
for (const r of gpu.rows.slice(0, 8)) {
lines.push(`- ${r.name}${r.incl_ms ?? "?"}ms`);
}
lines.push("");
}
if (mem?.rows?.length) {
lines.push(`## Memory tags`);
for (const r of mem.rows.slice(0, 10)) {
lines.push(`- ${JSON.stringify(r)}`);
}
lines.push("");
}
if (load?.rows?.length) {
lines.push(`## Slowest loads`);
for (const r of load.rows.slice(0, 10)) {
lines.push(`- ${r.name}${r.ms}ms`);
}
lines.push("");
}
const md = lines.join("\n");
let savedPath = null;
if (save) {
savedPath = path.join(traceDir(t.id, projectDir), "report.md");
fs.writeFileSync(savedPath, md);
}
return ok({ trace_id: t.id, saved_to: savedPath, markdown: md });
}
function parseOr(res) {
if (!res || !res.content) return null;
try { const o = JSON.parse(res.content[0].text); return res.isError ? { error: res.content[0].text } : o; }
catch { return null; }
}
// ---------- charts ----------
export async function handleRenderCharts({ trace_id, hitch_ms = 33, project_dir }) {
const projectDir = project_dir || DEFAULT_PROJECT_DIR;
const t = resolveTrace(trace_id, projectDir);
if (!t) return err(`trace not found: ${trace_id}`);
const dir = traceDir(t.id, projectDir);
const chartsDir = path.join(dir, "charts");
fs.mkdirSync(chartsDir, { recursive: true });
const db = openDb(dir);
let sections, frameCount = 0, hitchCount = 0;
try {
// ----- frames
const fm = findTable(db, ["frame_markers"]);
let frames = [];
if (fm) {
const cols = tableColumns(db, fm);
const start = pickColumn(cols, ["StartTime", "Time"]);
const dur = pickColumn(cols, ["Duration"]);
const thread = pickColumn(cols, ["ThreadName"]);
if (start && dur) {
const where = thread ? `WHERE LOWER("${thread}") LIKE '%game%'` : "";
frames = db.prepare(
`SELECT "${start}" AS start_s, "${dur}"*1000 AS ms FROM "${fm}" ${where} ORDER BY "${start}"`
).all();
}
}
frameCount = frames.length;
hitchCount = frames.filter((f) => f.ms >= hitch_ms).length;
// ----- per-thread totals (from timer_stats_* tables)
const tables = listTables(db);
const threadTotals = [];
const threadMap = [
["GameThread", "timer_stats_game"],
["RenderThread", "timer_stats_render"],
["RHIThread", "timer_stats_rhi"],
["GPU", "timer_stats_gpu"],
];
for (const [thrName, tbl] of threadMap) {
if (!tables.includes(tbl)) continue;
const cols = tableColumns(db, tbl);
const inclCol = pickColumn(cols, ["Incl"]);
if (!inclCol) continue;
// Sum of top-level (those without children inflating) is unreliable; use MAX
// of root scopes — but simplest: take MAX(Incl) across the table as a proxy
// for "thread total time". For "Frame" timer that == frame total.
const r = db.prepare(`SELECT MAX("${inclCol}") AS max_incl FROM "${tbl}"`).get();
threadTotals.push({ thread: thrName, total_s: r?.max_incl || 0 });
}
// ----- top scopes per thread (exclusive — what actually runs there)
function topFromTable(tbl, label, metric) {
if (!tables.includes(tbl)) return null;
const cols = tableColumns(db, tbl);
const nameCol = pickColumn(cols, ["Name"]);
const inclCol = pickColumn(cols, ["Incl"]);
const exclCol = pickColumn(cols, ["Excl"]);
const countCol = pickColumn(cols, ["Count"]);
const sortCol = metric === "excl" ? exclCol : inclCol;
if (!nameCol || !sortCol) return null;
const rows = db.prepare(
`SELECT "${nameCol}" AS name, "${inclCol}"*1000 AS incl_ms, "${exclCol}"*1000 AS excl_ms, "${countCol}" AS calls FROM "${tbl}" ORDER BY "${sortCol}" DESC LIMIT 15`
).all();
return { label, rows, metric };
}
const topGameExcl = topFromTable("timer_stats_game", "GameThread — top by EXCLUSIVE (what it actually does)", "excl");
const topRenderExcl = topFromTable("timer_stats_render", "RenderThread — top by EXCLUSIVE", "excl");
const topRhi = topFromTable("timer_stats_rhi", "RHIThread — top by INCLUSIVE", "incl");
const topGpu = topFromTable("timer_stats_gpu", "GPU — top by INCLUSIVE", "incl");
// ----- compose dashboard
sections = [];
sections.push({ full: true, svg: frameTimeline({ frames, hitchMs: hitch_ms, title: "Frame time (log Y) — every spike is a hitch" }),
note: "X axis = minutes into trace. Orange line = hitch threshold. Red dots = hitches." });
sections.push({ svg: frameHistogram({ frames, title: "Frame time distribution" }),
note: "Green = ≤16.6ms (60fps). Yellow = ≤33ms (30fps). Red = hitch." });
sections.push({ svg: threadBreakdown({ rows: threadTotals, title: "Thread time totals (proxy: max Incl per thread)" }),
note: "Rough comparison — GameThread is usually limiter." });
if (topGameExcl) sections.push({ svg: topScopesBar({ rows: topGameExcl.rows, title: topGameExcl.label, metric: "excl_ms" }) });
if (topRenderExcl) sections.push({ svg: topScopesBar({ rows: topRenderExcl.rows, title: topRenderExcl.label, metric: "excl_ms" }) });
if (topRhi) sections.push({ svg: topScopesBar({ rows: topRhi.rows, title: topRhi.label, metric: "incl_ms" }) });
if (topGpu) sections.push({ svg: topScopesBar({ rows: topGpu.rows, title: topGpu.label, metric: "incl_ms" }) });
} finally {
db.close();
}
// Write individual SVGs (handy if someone wants to embed one) + dashboard.
const written = [];
for (let i = 0; i < sections.length; i++) {
const p = path.join(chartsDir, `chart_${String(i).padStart(2, "0")}.svg`);
fs.writeFileSync(p, sections[i].svg);
written.push(p);
}
const html = buildDashboard({ label: t.label, traceId: t.id, sections });
const htmlPath = path.join(chartsDir, "dashboard.html");
fs.writeFileSync(htmlPath, html);
return ok({
trace_id: t.id,
dashboard: htmlPath,
svgs: written,
summary: {
frames: frameCount,
hitches: hitchCount,
memory_note: "No memory/LLM channels in this trace — record with -trace=...,memory,llm next time for RAM charts.",
},
open: `file:///${htmlPath.replace(/\\/g, "/")}`,
});
}

201
src/insightsChart.js Normal file
View File

@ -0,0 +1,201 @@
// Pure-SVG chart builders. No dependencies. Each function returns an
// `<svg>...</svg>` string sized to (width, height). Bundled together into
// a single HTML by the caller — open in any browser, no server needed.
//
// Style philosophy: dense, professional, monospace-ish. Designed to be
// scannable in a dashboard at 100% zoom. Dark theme to reduce eye strain
// when staring at perf data for an hour.
const COLORS = {
bg: "#1e1e2e",
panel: "#26273a",
axis: "#666",
grid: "#33344a",
text: "#cdd6f4",
muted: "#8b8fa0",
good: "#a6e3a1",
warn: "#f9e2af",
bad: "#f38ba8",
bar: "#89b4fa",
bar2: "#cba6f7",
line: "#94e2d5",
hitch: "#fab387",
};
function esc(s) { return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); }
function fmt(n) { if (n == null) return "—"; if (n >= 1000) return n.toFixed(0); if (n >= 10) return n.toFixed(1); return n.toFixed(2); }
// ----- frame timeline: ms per frame across time, log y (frames cluster low + hitch spikes)
export function frameTimeline({ frames, width = 1100, height = 280, hitchMs = 33, title = "Frame time" }) {
if (!frames.length) return placeholder(width, height, "no frames");
const padL = 60, padR = 20, padT = 30, padB = 40;
const W = width - padL - padR, H = height - padT - padB;
const tMin = frames[0].start_s;
const tMax = frames[frames.length - 1].start_s;
const tRange = Math.max(tMax - tMin, 1);
// Log scale on Y (frames are 530ms typical, hitches 1004000ms)
const yMin = 1, yMax = Math.max(50, ...frames.map((f) => f.ms));
const ly = (ms) => H - (Math.log10(Math.max(ms, yMin)) - Math.log10(yMin)) / (Math.log10(yMax) - Math.log10(yMin)) * H;
const xt = (t) => (t - tMin) / tRange * W;
// Downsample to ~2000 points for SVG perf
const stride = Math.max(1, Math.ceil(frames.length / 2000));
const pts = [];
for (let i = 0; i < frames.length; i += stride) {
const f = frames[i];
pts.push(`${xt(f.start_s).toFixed(1)},${ly(f.ms).toFixed(1)}`);
}
// Hitch markers: every frame > hitchMs (no downsample for these)
const hitches = frames.filter((f) => f.ms >= hitchMs);
const hitchDots = hitches.map((f) => `<circle cx="${xt(f.start_s).toFixed(1)}" cy="${ly(f.ms).toFixed(1)}" r="2" fill="${COLORS.bad}"/>`).join("");
// Y-axis ticks: log decades
const ticks = [];
for (const v of [5, 16.6, 33, 100, 500, 1000, 5000]) {
if (v >= yMin && v <= yMax) {
const y = ly(v) + padT;
ticks.push(`<line x1="${padL}" x2="${padL + W}" y1="${y}" y2="${y}" stroke="${COLORS.grid}" stroke-width="1"/>` +
`<text x="${padL - 6}" y="${y + 3}" fill="${COLORS.muted}" font-size="10" text-anchor="end">${v}ms</text>`);
}
}
// Hitch threshold line
const hy = ly(hitchMs) + padT;
// X-axis ticks: every 60s
const xticks = [];
for (let t = Math.ceil(tMin / 60) * 60; t <= tMax; t += 60) {
const x = xt(t) + padL;
xticks.push(`<line x1="${x}" x2="${x}" y1="${padT}" y2="${padT + H}" stroke="${COLORS.grid}" stroke-width="1"/>` +
`<text x="${x}" y="${height - 12}" fill="${COLORS.muted}" font-size="10" text-anchor="middle">${(t/60).toFixed(0)}m</text>`);
}
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">
<rect width="${width}" height="${height}" fill="${COLORS.panel}"/>
<text x="${padL}" y="18" fill="${COLORS.text}" font-size="13" font-family="system-ui">${esc(title)} <tspan fill="${COLORS.muted}">— ${frames.length} frames, ${hitches.length} hitches &gt;${hitchMs}ms</tspan></text>
${xticks.join("")}
${ticks.join("")}
<line x1="${padL}" x2="${padL + W}" y1="${hy}" y2="${hy}" stroke="${COLORS.hitch}" stroke-width="1" stroke-dasharray="4,3" opacity="0.7"/>
<text x="${padL + W - 4}" y="${hy - 4}" fill="${COLORS.hitch}" font-size="10" text-anchor="end">hitch threshold ${hitchMs}ms</text>
<polyline points="${pts.join(" ")}" fill="none" stroke="${COLORS.line}" stroke-width="0.8" opacity="0.8" transform="translate(${padL},${padT})"/>
<g transform="translate(${padL},${padT})">${hitchDots}</g>
</svg>`;
}
// ----- frame histogram: bucketed distribution
export function frameHistogram({ frames, width = 540, height = 280, title = "Frame time distribution" }) {
if (!frames.length) return placeholder(width, height, "no frames");
const padL = 40, padR = 20, padT = 30, padB = 40;
const W = width - padL - padR, H = height - padT - padB;
// Log-spaced buckets up to max
const max = Math.max(...frames.map((f) => f.ms));
const edges = [0, 8, 12, 16.6, 25, 33, 50, 100, 200, 500, 1000, 2000, 5000];
while (edges[edges.length - 1] < max) edges.push(edges[edges.length - 1] * 2);
const counts = new Array(edges.length - 1).fill(0);
for (const f of frames) {
for (let i = 1; i < edges.length; i++) {
if (f.ms < edges[i]) { counts[i - 1]++; break; }
}
}
const maxN = Math.max(...counts, 1);
const bw = W / counts.length;
const bars = counts.map((n, i) => {
const h = n / maxN * H;
const color = edges[i + 1] <= 16.6 ? COLORS.good : edges[i + 1] <= 33 ? COLORS.warn : COLORS.bad;
const tt = `${edges[i]}${edges[i + 1]}ms: ${n} frames`;
return `<g><rect x="${padL + i * bw + 1}" y="${padT + H - h}" width="${bw - 2}" height="${h}" fill="${color}" opacity="0.85"><title>${tt}</title></rect>` +
`<text x="${padL + i * bw + bw / 2}" y="${height - 12}" fill="${COLORS.muted}" font-size="9" text-anchor="middle">${edges[i + 1] >= 1000 ? (edges[i + 1] / 1000) + "s" : edges[i + 1]}</text></g>`;
}).join("");
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">
<rect width="${width}" height="${height}" fill="${COLORS.panel}"/>
<text x="${padL}" y="18" fill="${COLORS.text}" font-size="13" font-family="system-ui">${esc(title)}</text>
<text x="${padL}" y="${padT + H - 4}" fill="${COLORS.muted}" font-size="9">0</text>
<text x="${padL}" y="${padT + 8}" fill="${COLORS.muted}" font-size="9">${maxN}</text>
${bars}
</svg>`;
}
// ----- horizontal bar chart of top scopes
export function topScopesBar({ rows, width = 540, height = 360, title, metric = "incl_ms" }) {
if (!rows || !rows.length) return placeholder(width, height, "no scopes");
const padL = 220, padR = 70, padT = 30, padB = 20;
const W = width - padL - padR, H = height - padT - padB;
const data = rows.slice(0, 15);
const max = Math.max(...data.map((r) => r[metric] || 0), 1);
const rowH = H / data.length;
const bars = data.map((r, i) => {
const v = r[metric] || 0;
const bw = v / max * W;
const y = padT + i * rowH;
const cy = y + rowH / 2;
const label = String(r.name).slice(0, 30);
return `<text x="${padL - 8}" y="${cy + 3}" fill="${COLORS.text}" font-size="10" text-anchor="end" font-family="monospace">${esc(label)}</text>` +
`<rect x="${padL}" y="${y + 3}" width="${bw}" height="${rowH - 6}" fill="${COLORS.bar}" opacity="0.9"/>` +
`<text x="${padL + bw + 4}" y="${cy + 3}" fill="${COLORS.muted}" font-size="10" font-family="monospace">${fmt(v)}${metric.endsWith("_ms") ? "ms" : ""}${r.calls ? ` ×${r.calls}` : ""}</text>`;
}).join("");
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">
<rect width="${width}" height="${height}" fill="${COLORS.panel}"/>
<text x="12" y="18" fill="${COLORS.text}" font-size="13" font-family="system-ui">${esc(title)}</text>
${bars}
</svg>`;
}
// ----- thread breakdown bar (per-thread total inclusive)
export function threadBreakdown({ rows, width = 540, height = 220, title = "Per-thread total time" }) {
if (!rows || !rows.length) return placeholder(width, height, "no data");
const padL = 130, padR = 80, padT = 30, padB = 20;
const W = width - padL - padR, H = height - padT - padB;
const max = Math.max(...rows.map((r) => r.total_s || 0), 1);
const rowH = H / rows.length;
const palette = [COLORS.bar, COLORS.bar2, COLORS.line, COLORS.warn, COLORS.bad];
const bars = rows.map((r, i) => {
const v = r.total_s || 0;
const bw = v / max * W;
const y = padT + i * rowH;
const cy = y + rowH / 2;
return `<text x="${padL - 8}" y="${cy + 3}" fill="${COLORS.text}" font-size="11" text-anchor="end" font-family="monospace">${esc(r.thread)}</text>` +
`<rect x="${padL}" y="${y + 4}" width="${bw}" height="${rowH - 8}" fill="${palette[i % palette.length]}" opacity="0.85"/>` +
`<text x="${padL + bw + 4}" y="${cy + 3}" fill="${COLORS.muted}" font-size="10" font-family="monospace">${fmt(v)}s</text>`;
}).join("");
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">
<rect width="${width}" height="${height}" fill="${COLORS.panel}"/>
<text x="12" y="18" fill="${COLORS.text}" font-size="13" font-family="system-ui">${esc(title)}</text>
${bars}
</svg>`;
}
function placeholder(w, h, msg) {
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" width="${w}" height="${h}">
<rect width="${w}" height="${h}" fill="${COLORS.panel}"/>
<text x="${w/2}" y="${h/2}" fill="${COLORS.muted}" font-size="13" text-anchor="middle" font-family="system-ui">${esc(msg)}</text>
</svg>`;
}
// Build a single HTML page that embeds all SVGs side by side, dark theme.
export function buildDashboard({ label, traceId, sections }) {
const css = `
body { margin: 0; background: ${COLORS.bg}; color: ${COLORS.text}; font-family: system-ui; }
header { padding: 16px 24px; border-bottom: 1px solid ${COLORS.grid}; }
h1 { margin: 0; font-size: 18px; }
.sub { color: ${COLORS.muted}; font-size: 13px; margin-top: 4px; }
main { padding: 20px 24px; display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
.full { grid-column: 1 / -1; }
.card { background: ${COLORS.panel}; border-radius: 8px; overflow: hidden; }
.note { color: ${COLORS.muted}; font-size: 12px; padding: 8px 14px; border-top: 1px solid ${COLORS.grid}; }
svg { display: block; width: 100%; height: auto; }
`;
const html = `<!doctype html>
<html><head><meta charset="utf-8"><title>Insights ${esc(label)}</title><style>${css}</style></head>
<body>
<header>
<h1>Insights Dashboard — ${esc(label)}</h1>
<div class="sub">trace_id <code>${esc(traceId)}</code> · generated ${new Date().toISOString()}</div>
</header>
<main>
${sections.map((s) => `<div class="card${s.full ? " full" : ""}">${s.svg}${s.note ? `<div class="note">${esc(s.note)}</div>` : ""}</div>`).join("\n")}
</main>
</body></html>`;
return html;
}

207
src/insightsDb.js Normal file
View File

@ -0,0 +1,207 @@
// sqlite cache for parsed Insights exports.
//
// Design choice: schema is *inferred* from each TSV's header row, one table
// per file (basename → table). Insights renames/reorders columns between UE
// versions; pinning a hand-written schema would break on every upgrade. The
// analytics layer probes columns at query time and adapts.
//
// Numeric columns are detected by sniffing the first 50 non-empty values.
import fs from "node:fs";
import path from "node:path";
import readline from "node:readline";
let Database;
try {
({ default: Database } = await import("better-sqlite3"));
} catch (e) {
// surfaced lazily at first call
Database = null;
}
export function dbAvailable() { return Database !== null; }
export function openDb(cacheDir) {
if (!Database) throw new Error("better-sqlite3 not installed. Run `npm install` in Plugins/UEBlueprintMCP.");
const dbPath = path.join(cacheDir, "cache.sqlite");
const db = new Database(dbPath);
db.pragma("journal_mode = WAL");
db.pragma("synchronous = NORMAL");
return db;
}
const TABLE_SAFE = /^[a-z0-9_]+$/i;
function sanitizeIdent(s) {
return s.replace(/[^a-zA-Z0-9_]/g, "_").replace(/^_+|_+$/g, "") || "col";
}
// Returns array of cleaned, unique column names matching original order.
function cleanHeaders(headers) {
const seen = new Map();
return headers.map((h) => {
let name = sanitizeIdent(h.trim());
if (!name) name = "col";
if (/^\d/.test(name)) name = "_" + name;
const n = (seen.get(name) || 0) + 1;
seen.set(name, n);
return n === 1 ? name : `${name}_${n}`;
});
}
// Streams a TSV/CSV file, returns { headers, types, rows }.
// types: "INTEGER" | "REAL" | "TEXT" per column.
export async function parseTabular(filePath) {
// Insights is inconsistent: ExportTimerStatistics writes comma-separated
// data into a .tsv file. So always sniff — don't trust the extension.
const sep = await sniffSeparator(filePath);
const stream = fs.createReadStream(filePath, { encoding: "utf8" });
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
let headers = null;
const sniff = []; // first ~50 rows used for type inference
const allRows = [];
let lineNo = 0;
for await (const raw of rl) {
lineNo++;
if (!raw) continue;
// Insights sometimes emits a leading "Sep=," BOM-style line — skip.
if (lineNo === 1 && /^sep=/i.test(raw)) continue;
const cells = splitRow(raw, sep);
if (!headers) {
headers = cleanHeaders(cells);
continue;
}
allRows.push(cells);
if (sniff.length < 50) sniff.push(cells);
}
if (!headers) return { headers: [], types: [], rows: [] };
const types = headers.map((_, i) => inferType(sniff.map((r) => r[i])));
return { headers, types, rows: allRows };
}
async function sniffSeparator(filePath) {
const fd = await fs.promises.open(filePath, "r");
const buf = Buffer.alloc(4096);
const { bytesRead } = await fd.read(buf, 0, buf.length, 0);
await fd.close();
const head = buf.slice(0, bytesRead).toString("utf8");
const firstLine = head.split(/\r?\n/, 1)[0] || "";
const tabs = (firstLine.match(/\t/g) || []).length;
const commas = (firstLine.match(/,/g) || []).length;
return tabs >= commas ? "\t" : ",";
}
// Minimal CSV-aware splitter. Handles "quoted, fields" with "" escapes.
// For TAB separator we don't bother with quoting (Insights doesn't quote tabs).
function splitRow(line, sep) {
if (sep === "\t") return line.split("\t");
const out = [];
let cur = "";
let inQuote = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (inQuote) {
if (ch === '"' && line[i + 1] === '"') { cur += '"'; i++; }
else if (ch === '"') inQuote = false;
else cur += ch;
} else {
if (ch === '"') inQuote = true;
else if (ch === sep) { out.push(cur); cur = ""; }
else cur += ch;
}
}
out.push(cur);
return out;
}
function inferType(samples) {
let int = true, real = true, seenAny = false;
for (const s of samples) {
if (s === undefined || s === null || s === "") continue;
seenAny = true;
const v = s.trim();
if (!/^-?\d+$/.test(v)) int = false;
if (!/^-?\d+(\.\d+)?([eE][-+]?\d+)?$/.test(v)) real = false;
if (!int && !real) break;
}
if (!seenAny) return "TEXT";
if (int) return "INTEGER";
if (real) return "REAL";
return "TEXT";
}
// Ingest one TSV file into a table. Drops & recreates so re-ingest is idempotent.
export function ingestFile(db, tableName, parsed) {
if (!TABLE_SAFE.test(tableName)) throw new Error(`unsafe table name: ${tableName}`);
const { headers, types, rows } = parsed;
if (headers.length === 0) return { table: tableName, rows: 0, skipped: "empty" };
db.exec(`DROP TABLE IF EXISTS ${tableName}`);
const colDefs = headers.map((h, i) => `"${h}" ${types[i]}`).join(", ");
db.exec(`CREATE TABLE ${tableName} (${colDefs})`);
const placeholders = headers.map(() => "?").join(", ");
const stmt = db.prepare(`INSERT INTO ${tableName} VALUES (${placeholders})`);
const insertMany = db.transaction((rs) => {
for (const r of rs) {
const vals = headers.map((_, i) => coerce(r[i], types[i]));
stmt.run(...vals);
}
});
insertMany(rows);
// Helpful indexes when columns exist.
for (const idxCol of ["Thread", "ThreadName", "Frame", "FrameIndex", "Name", "TimerName"]) {
if (headers.includes(idxCol)) {
try { db.exec(`CREATE INDEX idx_${tableName}_${idxCol} ON ${tableName}("${idxCol}")`); } catch { /* ignore */ }
}
}
return { table: tableName, rows: rows.length, columns: headers, types };
}
function coerce(v, type) {
if (v === undefined || v === null || v === "") return null;
if (type === "INTEGER") {
const n = parseInt(v, 10);
return Number.isFinite(n) ? n : null;
}
if (type === "REAL") {
const n = parseFloat(v);
return Number.isFinite(n) ? n : null;
}
return String(v);
}
// Ingest every .tsv/.csv in rawDir as a table named after its basename.
export async function ingestRawDir(db, rawDir) {
const files = fs.readdirSync(rawDir).filter((f) => /\.(tsv|csv)$/i.test(f));
const summary = [];
for (const f of files) {
const table = sanitizeIdent(path.basename(f, path.extname(f))).toLowerCase();
try {
const parsed = await parseTabular(path.join(rawDir, f));
summary.push(ingestFile(db, table, parsed));
} catch (e) {
summary.push({ table, file: f, error: e.message });
}
}
// Save a manifest of available tables for introspection.
db.exec(`CREATE TABLE IF NOT EXISTS _mcp_manifest (key TEXT PRIMARY KEY, value TEXT)`);
db.prepare(`INSERT OR REPLACE INTO _mcp_manifest VALUES (?, ?)`).run("ingest", JSON.stringify(summary));
db.prepare(`INSERT OR REPLACE INTO _mcp_manifest VALUES (?, ?)`).run("ingest_time", new Date().toISOString());
return summary;
}
export function listTables(db) {
return db.prepare(
`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE '\\_%' ESCAPE '\\' ORDER BY name`
).all().map((r) => r.name);
}
export function tableColumns(db, table) {
if (!TABLE_SAFE.test(table)) return [];
return db.prepare(`PRAGMA table_info("${table}")`).all().map((r) => ({ name: r.name, type: r.type }));
}

154
src/insightsExporter.js Normal file
View File

@ -0,0 +1,154 @@
// Wraps UnrealInsights.exe to export an .utrace file into CSVs.
//
// Strategy: run `UnrealInsights.exe -OpenTraceFile=<path> -AutoQuit
// -ABSLog=<log> -ExecOnAnalysisCompleteCmd="cmd1;cmd2;..."` which loads
// the trace headless, executes the given Trace*.Export* console commands,
// then quits. Output CSVs land in cacheDir/raw/.
//
// Robustness:
// - locates UnrealInsights.exe via projectPaths (env override > the project's
// associated engine > a scan of installed engines)
// - skips export commands silently if the channel isn't in the trace
// (we detect this post-hoc by which CSVs got written)
// - has a hard timeout; partial output is still usable.
import { spawn } from "node:child_process";
import fs from "node:fs/promises";
import fssync from "node:fs";
import path from "node:path";
import crypto from "node:crypto";
import { insightsExe } from "./projectPaths.js";
// Locates UnrealInsights.exe purely from the resolved engine (env override >
// the project's associated engine > scan of installed Epic engines). No
// machine-specific paths baked in here — see projectPaths.js.
export function findInsightsExe() {
return insightsExe();
}
export async function hashTrace(tracePath) {
// Hash header + size + mtime — quick, avoids reading multi-GB trace.
const stat = await fs.stat(tracePath);
const fd = await fs.open(tracePath, "r");
const buf = Buffer.alloc(Math.min(64 * 1024, stat.size));
await fd.read(buf, 0, buf.length, 0);
await fd.close();
const h = crypto.createHash("sha1");
h.update(buf);
h.update(String(stat.size));
h.update(String(stat.mtimeMs | 0));
return h.digest("hex").slice(0, 16);
}
// Export commands. UE 5.8 only honours ONE command via -ExecOnAnalysisCompleteCmd
// — for multiples you must use a response file: `-ExecOnAnalysisCompleteCmd="@=path.rsp"`
// with one command per line. Pattern lifted from
// Engine/Source/Developer/TraceInsights/Private/Insights/Tests/FunctionalTests/ExportCommandsTests.cpp
//
// Strategy for big traces:
// - ExportTimerStatistics → aggregated stats, small even for 158M scopes
// - ExportThreads / ExportTimers → tiny metadata
// - ExportTimingEvents → only frame-marker events (filtered by timer name)
// to keep size sane while still letting us compute frame-time stats
// - ExportCounters → for FPS, memory, custom counters
//
// Memory tags / loadtime exports only make sense when those channels were
// recorded — they're separate commands and would be no-ops here.
function fwd(p) { return p.replace(/\\/g, "/"); }
function buildResponseFile(rawDir) {
const f = (name) => `"${fwd(path.join(rawDir, name))}"`;
// Order doesn't matter much; each line is executed independently.
// Notes on filter syntax (from TimingProfilerManager.cpp, UE 5.8):
// -threads=Name1,Name2 inclusive thread filter, matches thread Name exactly
// -timers=Name1,Name2 inclusive timer filter, partial match — keep narrow
// Thread names in UE: "Game Frames" / "Rendering Frames" are virtual
// frame-marker threads (group "Frame Tracks"); GPU queues are
// GPU0-Graphics0 / GPU0-Compute0 / GPU0-Copy0; also GPU1, GPU2 (multi-GPU).
return [
`TimingInsights.ExportThreads ${f("threads.tsv")}`,
`TimingInsights.ExportTimers ${f("timers.tsv")}`,
// Global aggregated stats — primary source for top_scopes / report.
`TimingInsights.ExportTimerStatistics ${f("timer_stats_all.tsv")} -columns=* -sortby=totalinclusivetime -sortorder=descending`,
// Per-thread aggregated stats — lets us answer "what's heavy on GameThread vs RenderThread".
`TimingInsights.ExportTimerStatistics ${f("timer_stats_game.tsv")} -threads=GameThread -columns=* -sortby=totalinclusivetime -sortorder=descending`,
`TimingInsights.ExportTimerStatistics ${f("timer_stats_render.tsv")} -threads="RenderThread 0" -columns=* -sortby=totalinclusivetime -sortorder=descending`,
`TimingInsights.ExportTimerStatistics ${f("timer_stats_rhi.tsv")} -threads=RHIThread -columns=* -sortby=totalinclusivetime -sortorder=descending`,
`TimingInsights.ExportTimerStatistics ${f("timer_stats_gpu.tsv")} -threads=GPU0-Graphics0,GPU0-Compute0,GPU0-Copy0,GPU1,GPU2 -columns=* -sortby=totalinclusivetime -sortorder=descending`,
// Frame markers — only the "Game Frames" virtual thread, small.
`TimingInsights.ExportTimingEvents ${f("frame_markers.tsv")} -threads="Game Frames" -columns=*`,
// GPU timing events on primary graphics queue (for top GPU events list).
`TimingInsights.ExportTimingEvents ${f("gpu_events.tsv")} -threads=GPU0-Graphics0,GPU1 -columns=*`,
`TimingInsights.ExportCounters ${f("counters.tsv")}`,
].join("\n");
}
export async function exportTrace({ tracePath, cacheDir, timeoutMs = 15 * 60 * 1000 }) {
const exe = findInsightsExe();
if (!exe) {
return { ok: false, error: "UnrealInsights.exe not found. Set env UE_INSIGHTS_EXE." };
}
if (!fssync.existsSync(tracePath)) {
return { ok: false, error: `trace file not found: ${tracePath}` };
}
const rawDir = path.join(cacheDir, "raw");
await fs.mkdir(rawDir, { recursive: true });
const logPath = path.join(cacheDir, "insights.log");
const rspPath = path.join(cacheDir, "export.rsp");
await fs.writeFile(rspPath, buildResponseFile(rawDir), "utf8");
const args = [
`-OpenTraceFile=${fwd(tracePath)}`,
"-AutoQuit",
"-NoUI",
"-Unattended",
`-ABSLog=${fwd(logPath)}`,
`-ExecOnAnalysisCompleteCmd=@=${fwd(rspPath)}`,
];
const t0 = Date.now();
const res = await runProcess(exe, args, timeoutMs);
const elapsedMs = Date.now() - t0;
const produced = (await fs.readdir(rawDir)).filter((f) => f.endsWith(".tsv") || f.endsWith(".csv"));
return {
ok: produced.length > 0,
exe,
args,
rspPath,
rspBody: await fs.readFile(rspPath, "utf8"),
elapsedMs,
produced,
rawDir,
logPath,
exitCode: res.exitCode,
timedOut: res.timedOut,
stderr: res.stderr.slice(-2000),
error: produced.length === 0 ? "no CSV files were exported — see insights.log" : undefined,
};
}
function runProcess(exe, args, timeoutMs) {
return new Promise((resolve) => {
const proc = spawn(exe, args, { windowsHide: true });
let stderr = "";
let stdout = "";
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
try { proc.kill("SIGKILL"); } catch { /* ignore */ }
}, timeoutMs);
proc.stdout?.on("data", (b) => { stdout += b.toString(); });
proc.stderr?.on("data", (b) => { stderr += b.toString(); });
proc.on("close", (code) => {
clearTimeout(timer);
resolve({ exitCode: code, timedOut, stdout, stderr });
});
proc.on("error", (e) => {
clearTimeout(timer);
resolve({ exitCode: -1, timedOut, stdout, stderr: stderr + "\n" + e.message });
});
});
}

263
src/launcher.js Normal file
View File

@ -0,0 +1,263 @@
// launcher.js — JS-native handler for the UE Project Launcher. Reads & edits
// launch profiles WITHOUT touching UE Remote Control, so it keeps working even
// when the editor is busy or crashed.
//
// Covers BOTH launchers: the Legacy Project Launcher / Session Frontend AND the
// new Project Launcher (UE 5.5+, Engine/Plugins/Developer/ProjectLauncher).
// Verified via the new plugin's binary: it owns no file I/O and drives the same
// ILauncherProfileManager (LauncherServices) — symbols HandleProfileManager
// ProfileAdded/Removed, CreateCustomProfile→ILauncherProfile — so both UIs
// persist to the SAME *.ulp2 files. The new launcher's "Basic" profiles are
// transient (per build target/device) and never written to disk; only saved
// Custom/Advanced profiles land here.
//
// Profiles are plain JSON files (`*.ulp2`, "Version": 42) that the Launcher
// loads from <EngineDir>/Engine/Programs/UnrealFrontend/Profiles/. Editing the
// file IS editing the profile — the Launcher re-reads this folder on open.
//
// CAVEAT: if the Launcher window is already open it holds the profile in memory
// and may overwrite your file edit on its next save. Edit with the Launcher
// closed (or reopen it afterwards) for changes to stick.
import { promises as fs } from "node:fs";
import path from "node:path";
import crypto from "node:crypto";
import { engineDir as detectEngineDir } from "./projectPaths.js";
const PROFILE_EXT = ".ulp2";
// Engine root WITHOUT the trailing /Engine. Auto-detected from the host project
// (see projectPaths.js). Override per-call via {engine_dir} or env UE_ENGINE_DIR,
// or point straight at the folder with {profiles_dir} / env UE_PROFILES_DIR.
function profilesDir(args = {}) {
if (args.profiles_dir) return args.profiles_dir.replace(/\\/g, "/");
if (process.env.UE_PROFILES_DIR) return process.env.UE_PROFILES_DIR.replace(/\\/g, "/");
const eng = detectEngineDir(args.engine_dir);
if (!eng) {
throw new Error(
"Could not locate an Unreal Engine install for launcher profiles. " +
"Set UE_ENGINE_DIR / UE_PROFILES_DIR or pass engine_dir / profiles_dir."
);
}
return `${eng.replace(/\\/g, "/")}/Engine/Programs/UnrealFrontend/Profiles`;
}
async function listFiles(dir) {
let names;
try {
names = await fs.readdir(dir);
} catch (e) {
const err = new Error(`profiles folder not found: ${dir} (${e.code}). Pass {profiles_dir} or set UE_PROFILES_DIR.`);
err.code = "NO_DIR";
throw err;
}
return names.filter((n) => n.toLowerCase().endsWith(PROFILE_EXT)).map((n) => path.join(dir, n));
}
async function readProfileFile(file) {
const txt = await fs.readFile(file, "utf8");
return JSON.parse(txt);
}
function summarize(json, file) {
return {
file: path.basename(file),
id: json.Id,
name: json.Name,
description: json.Description || "",
project: json.ShareableProjectPath || json.ProjectPath || "",
build_config: json.BuildConfiguration,
cooked_maps: json.CookedMaps || [],
cooked_platforms: json.CookedPlatforms || [],
package_dir: json.PackageDir || "",
build_mode: json.BuildMode,
cook_mode: json.CookMode,
deployment_mode: json.DeploymentMode,
};
}
// Resolve a single profile from {file|id|name}. Returns {file, json}.
async function select(args) {
const dir = profilesDir(args);
const files = await listFiles(dir);
if (args.file) {
const want = path.isAbsolute(args.file) ? args.file : path.join(dir, args.file);
const hit = files.find((f) => path.resolve(f) === path.resolve(want)) ||
files.find((f) => path.basename(f).toLowerCase() === String(args.file).toLowerCase());
if (!hit) throw new Error(`profile file not found: ${args.file}`);
return { file: hit, json: await readProfileFile(hit) };
}
const loaded = await Promise.all(files.map(async (f) => ({ file: f, json: await readProfileFile(f).catch(() => null) })));
const valid = loaded.filter((x) => x.json);
let matches = valid;
if (args.id) {
const id = String(args.id).toUpperCase();
matches = valid.filter((x) => String(x.json.Id).toUpperCase() === id);
} else if (args.name) {
const nm = String(args.name).toLowerCase();
matches = valid.filter((x) => String(x.json.Name).toLowerCase() === nm);
if (matches.length === 0) matches = valid.filter((x) => String(x.json.Name).toLowerCase().includes(nm));
} else {
throw new Error("specify one of {id, name, file} to select a profile");
}
if (matches.length === 0) throw new Error(`no profile matched ${JSON.stringify({ id: args.id, name: args.name })}`);
if (matches.length > 1) {
throw new Error(`ambiguous selector — ${matches.length} profiles matched: ` +
matches.map((m) => `${m.json.Name} (${m.json.Id})`).join(", "));
}
return matches[0];
}
async function backup(file) {
const bak = `${file}.bak`;
await fs.copyFile(file, bak).catch(() => {});
return path.basename(bak);
}
// UE writes these files with tab indentation; match it to keep diffs minimal.
async function writeProfile(file, json) {
await fs.writeFile(file, JSON.stringify(json, null, "\t") + "\n", "utf8");
}
function newId() {
return crypto.randomUUID().replace(/-/g, "").toUpperCase(); // 32 hex chars, UE-style
}
// ---- ops -------------------------------------------------------------------
async function opList(args) {
const dir = profilesDir(args);
const files = await listFiles(dir);
const out = [];
for (const f of files) {
try { out.push(summarize(await readProfileFile(f), f)); }
catch (e) { out.push({ file: path.basename(f), error: `parse failed: ${e.message}` }); }
}
return { ok: true, profiles_dir: dir, count: out.length, profiles: out };
}
async function opRead(args) {
const { file, json } = await select(args);
return { ok: true, file: path.basename(file), profile: json };
}
async function opGet(args) {
if (!args.field) throw new Error("get: 'field' required");
const { file, json } = await select(args);
return { ok: true, file: path.basename(file), field: args.field, value: json[args.field] };
}
async function opSetField(args) {
if (!args.field) throw new Error("set_field: 'field' required");
if (!("value" in args)) throw new Error("set_field: 'value' required");
const { file, json } = await select(args);
const before = json[args.field];
json[args.field] = args.value;
const bak = await backup(file);
await writeProfile(file, json);
return { ok: true, file: path.basename(file), field: args.field, before, after: args.value, backup: bak };
}
async function opSetFields(args) {
if (!args.fields || typeof args.fields !== "object") throw new Error("set_fields: 'fields' object required");
const { file, json } = await select(args);
const changes = {};
for (const [k, v] of Object.entries(args.fields)) { changes[k] = { before: json[k], after: v }; json[k] = v; }
const bak = await backup(file);
await writeProfile(file, json);
return { ok: true, file: path.basename(file), changes, backup: bak };
}
async function opSetCookedMaps(args) {
if (!Array.isArray(args.maps)) throw new Error("set_cooked_maps: 'maps' array required");
const { file, json } = await select(args);
const before = json.CookedMaps || [];
json.CookedMaps = args.maps;
const bak = await backup(file);
await writeProfile(file, json);
return { ok: true, file: path.basename(file), before, after: json.CookedMaps, backup: bak };
}
async function opAddCookedMap(args) {
if (!args.map) throw new Error("add_cooked_map: 'map' required");
const { file, json } = await select(args);
const maps = json.CookedMaps || [];
if (maps.includes(args.map)) return { ok: true, file: path.basename(file), unchanged: true, cooked_maps: maps };
maps.push(args.map);
json.CookedMaps = maps;
const bak = await backup(file);
await writeProfile(file, json);
return { ok: true, file: path.basename(file), added: args.map, cooked_maps: maps, backup: bak };
}
async function opRemoveCookedMap(args) {
if (!args.map) throw new Error("remove_cooked_map: 'map' required");
const { file, json } = await select(args);
const maps = json.CookedMaps || [];
if (!maps.includes(args.map)) return { ok: true, file: path.basename(file), unchanged: true, cooked_maps: maps };
json.CookedMaps = maps.filter((m) => m !== args.map);
const bak = await backup(file);
await writeProfile(file, json);
return { ok: true, file: path.basename(file), removed: args.map, cooked_maps: json.CookedMaps, backup: bak };
}
async function opDuplicate(args) {
const src = await select(args); // selects the template via {id|name|file}
const newName = args.new_name || `${src.json.Name} copy`;
const dir = profilesDir(args);
const json = JSON.parse(JSON.stringify(src.json));
const id = newId();
json.Id = id;
json.Name = newName;
// Apply any field overrides on the freshly-created copy.
if (args.fields && typeof args.fields === "object") {
for (const [k, v] of Object.entries(args.fields)) json[k] = v;
}
const safe = newName.replace(/[\\/:*?"<>|]/g, "_").toUpperCase();
const file = path.join(dir, `${safe}_${id}${PROFILE_EXT}`);
await writeProfile(file, json);
return { ok: true, created: path.basename(file), id, name: newName, from: src.json.Name };
}
// Soft delete: move the file into a Profiles/_mcp_trash subfolder (reversible),
// never a hard delete.
async function opDelete(args) {
const { file } = await select(args);
const dir = profilesDir(args);
const trash = path.join(dir, "_mcp_trash");
await fs.mkdir(trash, { recursive: true });
const dest = path.join(trash, path.basename(file));
await fs.rename(file, dest);
return { ok: true, trashed: path.basename(file), moved_to: `_mcp_trash/${path.basename(file)}`, restore_hint: "move it back to the Profiles folder to restore" };
}
const OPS = {
list: opList,
read: opRead,
get: opGet,
set_field: opSetField,
set_fields: opSetFields,
set_cooked_maps: opSetCookedMaps,
add_cooked_map: opAddCookedMap,
remove_cooked_map: opRemoveCookedMap,
duplicate: opDuplicate,
create_profile: opDuplicate, // alias — always template off an existing profile
delete: opDelete,
};
export async function handleLauncherOp(args) {
const op = args && args.op;
const fn = op && OPS[op];
if (!fn) {
return errResult(`launcher_op: unknown op '${op}'. Available: ${Object.keys(OPS).join(", ")}`);
}
try {
return okResult(await fn(args));
} catch (e) {
return errResult(`launcher_op ${op} failed: ${e.message}`);
}
}
// Local result helpers (mirror server.js so this module is self-contained).
function okResult(obj) { return { content: [{ type: "text", text: JSON.stringify(obj, null, 2) }] }; }
function errResult(msg) { return { content: [{ type: "text", text: msg }], isError: true }; }

148
src/nodeSpec.js Normal file
View File

@ -0,0 +1,148 @@
// NodeSpec / EdgeSpec — the universal primitive for spawning K2 nodes.
//
// A NodeSpec describes ONE Blueprint node. All ~15 supported `kind`s map to a
// concrete UK2Node_* class on the UE side. The `target` field disambiguates
// which function / variable / type the node refers to.
export const NODE_KINDS = new Set([
// Events & entry
"event", // UK2Node_Event — target: event name (BeginPlay, Tick, ReceiveActorBeginOverlap…)
"custom_event", // UK2Node_CustomEvent — target: custom event name
"component_event", // UK2Node_ComponentBoundEvent — target: "ComponentName.DelegateName"
// Function calls
"call_function", // UK2Node_CallFunction — target: "Class.Function"
// Variables
"variable_get", // UK2Node_VariableGet — target: variable name
"variable_set", // UK2Node_VariableSet — target: variable name
"self", // UK2Node_Self
// Flow control
"branch", // UK2Node_IfThenElse
"sequence", // UK2Node_ExecutionSequence — params.then_count: int
"switch_int", // UK2Node_SwitchInteger
"switch_string", // UK2Node_SwitchString
"switch_enum", // UK2Node_SwitchEnum — target: enum path "/Game/MyEnum.MyEnum"
// Macros (StandardMacros): ForLoop, ForEachLoop, WhileLoop, DoOnce, Gate, FlipFlop, MultiGate, IsValid, DoN
"macro", // UK2Node_MacroInstance — target: macro name
// Cast
"cast", // UK2Node_DynamicCast — target: class path
// Collections / structs
"make_array", // UK2Node_MakeArray
"make_struct", // UK2Node_MakeStruct — target: struct path
"break_struct", // UK2Node_BreakStruct — target: struct path
"set_fields_in_struct", // UK2Node_SetFieldsInStruct — target: struct path
// Misc commonly-used
"spawn_actor", // UK2Node_SpawnActorFromClass
"format_text", // UK2Node_FormatText
"get_subsystem", // UK2Node_GetSubsystem — target: subsystem class path
"load_asset", // UK2Node_LoadAsset
// Delegates
"bind_delegate", // UK2Node_AddDelegate — target: "ComponentName.DelegateName"
"unbind_delegate", // UK2Node_RemoveDelegate — target: "ComponentName.DelegateName"
"call_delegate", // UK2Node_CallDelegate — target: dispatcher name on this BP
"assign_delegate", // UK2Node_AssignDelegate — target: "ComponentName.DelegateName"
// Tier 2
"knot", // UK2Node_Knot — reroute
"comment", // UEdGraphNode_Comment — params: {text, width, height}
"enum_literal", // UK2Node_EnumLiteral — target: enum path
"get_class_defaults", // UK2Node_GetClassDefaults — target: class path
"function_result", // UK2Node_FunctionResult — return node for function graphs
"get_array_item", // UK2Node_GetArrayItem
"operator", // UK2Node_PromotableOperator — target: "+", "-", "*", "==", etc.
]);
export function validateGraph(graph) {
const errors = [];
if (!graph || typeof graph !== "object") {
return ["graph must be an object"];
}
const { bp_path, nodes, edges } = graph;
// bp_path is optional now: when omitted, the UE side auto-detects the
// currently open Blueprint editor (most-recently-focused asset).
if (bp_path !== undefined && bp_path !== null && bp_path !== "") {
if (typeof bp_path !== "string" || !bp_path.startsWith("/Game/")) {
errors.push(`bp_path must be a /Game/... path when provided (got ${JSON.stringify(bp_path)})`);
}
}
if (!Array.isArray(nodes)) {
errors.push("nodes must be an array");
return errors;
}
const ids = new Set();
for (const [i, n] of nodes.entries()) {
if (!n || typeof n !== "object") {
errors.push(`nodes[${i}] not an object`);
continue;
}
if (typeof n.id !== "string" || !n.id) {
errors.push(`nodes[${i}].id missing`);
} else if (ids.has(n.id)) {
errors.push(`duplicate node id "${n.id}"`);
} else {
ids.add(n.id);
}
if (!NODE_KINDS.has(n.kind)) {
errors.push(`nodes[${i}].kind "${n.kind}" not supported`);
}
if (n.position && (!Array.isArray(n.position) || n.position.length !== 2)) {
errors.push(`nodes[${i}].position must be [x,y]`);
}
}
if (edges !== undefined) {
if (!Array.isArray(edges)) {
errors.push("edges must be an array");
} else {
for (const [i, e] of edges.entries()) {
if (!e || !Array.isArray(e.from) || !Array.isArray(e.to)
|| e.from.length !== 2 || e.to.length !== 2) {
errors.push(`edges[${i}] must be {from:[id,pin], to:[id,pin]}`);
continue;
}
// @guid:<NodeGuid> lets you reference an EXISTING node in the graph.
const isGuidRef = (s) => typeof s === "string" && s.startsWith("@guid:");
if (!isGuidRef(e.from[0]) && !ids.has(e.from[0])) errors.push(`edges[${i}].from references unknown node "${e.from[0]}"`);
if (!isGuidRef(e.to[0]) && !ids.has(e.to[0])) errors.push(`edges[${i}].to references unknown node "${e.to[0]}"`);
}
}
}
return errors;
}
export const APPLY_GRAPH_SCHEMA = {
type: "object",
required: ["nodes"],
properties: {
bp_path: { type: "string", description: "Blueprint asset path, e.g. /Game/Blueprints/BP_Foo. If omitted, the currently focused open Blueprint editor is used." },
function: { type: "string", description: "Graph to edit. 'EventGraph' (default), or a function/macro name." },
mode: { type: "string", enum: ["append", "replace_function", "replace_event"], description: "How to merge into the graph. Default: append." },
layout: { type: "string", enum: ["preserve", "compact", "columns"], description: "Node layout mode. Default: preserve explicit positions. Use compact/columns to repack generated nodes." },
auto_layout: { type: "boolean", description: "Legacy shortcut for layout=compact. Default: false, so explicit positions are preserved." },
nodes: {
type: "array",
items: {
type: "object",
required: ["id", "kind"],
properties: {
id: { type: "string", description: "Local id used by edges within this batch." },
kind: { type: "string", enum: [...NODE_KINDS] },
target: { type: "string", description: "Function path / variable name / type — see kind docs." },
position: { type: "array", items: { type: "number" }, minItems: 2, maxItems: 2 },
params: { type: "object", description: "Pin default literals, keyed by pin name." },
flags: { type: "object", description: "pure, latent, self_context, etc." }
}
}
},
edges: {
type: "array",
items: {
type: "object",
required: ["from", "to"],
properties: {
from: { type: "array", minItems: 2, maxItems: 2, items: { type: "string" } },
to: { type: "array", minItems: 2, maxItems: 2, items: { type: "string" } }
}
}
},
dry_run: { type: "boolean", description: "If true, return the generated Python without sending to UE." },
validate: { type: "boolean", description: "If true, resolve all targets (classes, functions, structs, enums) inside UE without spawning. Returns {missing:[], resolved:[]}." }
}
};

240
src/projectPaths.js Normal file
View File

@ -0,0 +1,240 @@
// projectPaths.js — zero-config, portable resolution of the host UE project and
// engine. NOTHING here is hardcoded to a particular project or machine: every
// value is derived from where this plugin lives on disk, with environment-
// variable overrides for the few things that can't be inferred.
//
// The plugin is expected to live at <Project>/Plugins/<AnyFolder>/ — i.e. this
// file sits at <Project>/Plugins/<AnyFolder>/src/projectPaths.js. We walk up
// from here to the nearest directory containing a *.uproject, and everything
// (project name, Editor target, log file) follows from that.
//
// Overrides (all optional):
// UE_PROJECT_DIR — absolute path to the .uproject parent folder
// UE_UPROJECT_NAME — e.g. MyGame.uproject
// UE_BUILD_TARGET — e.g. MyGameEditor
// UE_ENGINE_DIR — installed engine root (the folder that contains /Engine)
// UE_INSIGHTS_EXE — full path to UnrealInsights.exe
import fs from "node:fs";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { fileURLToPath } from "node:url";
const HERE = path.dirname(fileURLToPath(import.meta.url)); // <plugin>/src
export const PLUGIN_DIR = path.resolve(HERE, ".."); // <plugin>
const norm = (p) => (p ? p.replace(/\\/g, "/") : p);
// ---------------------------------------------------------------------------
// Project discovery
// ---------------------------------------------------------------------------
let _project = null;
function discoverProject() {
if (_project) return _project;
// 1. Explicit override.
const envDir = process.env.UE_PROJECT_DIR;
if (envDir && fs.existsSync(envDir)) {
_project = fromDir(envDir) || { projectDir: norm(envDir), uproject: null, projectName: null };
return _project;
}
// 2. Walk up from the plugin folder to the nearest *.uproject.
let dir = PLUGIN_DIR;
for (let i = 0; i < 10; i++) {
const found = fromDir(dir);
if (found) { _project = found; return _project; }
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
}
// 3. Give up gracefully — projectDir is the plugin's grandparent's parent
// (the conventional <Project>/Plugins/<Plugin> layout) so callers still
// get a usable directory even if no .uproject was found.
_project = { projectDir: norm(path.resolve(PLUGIN_DIR, "..", "..")), uproject: null, projectName: null };
return _project;
}
function fromDir(dir) {
let entries;
try { entries = fs.readdirSync(dir); } catch { return null; }
const up = entries.find((f) => f.toLowerCase().endsWith(".uproject"));
if (!up) return null;
const override = process.env.UE_UPROJECT_NAME;
const uprojectName = override && entries.includes(override) ? override : up;
return {
projectDir: norm(dir),
uproject: norm(path.join(dir, uprojectName)),
projectName: uprojectName.replace(/\.uproject$/i, ""),
};
}
export function projectDir() { return discoverProject().projectDir; }
export function uprojectPath() { return discoverProject().uproject; }
export function uprojectName() {
if (process.env.UE_UPROJECT_NAME) return process.env.UE_UPROJECT_NAME;
const up = discoverProject().uproject;
return up ? path.basename(up) : null;
}
export function projectName() { return discoverProject().projectName; }
// The Editor build target. Convention is "<ProjectName>Editor"; a project can
// override via env or by naming its .Target.cs differently.
export function buildTarget() {
if (process.env.UE_BUILD_TARGET) return process.env.UE_BUILD_TARGET;
const name = projectName();
return name ? `${name}Editor` : null;
}
// Default UE log file name is "<ProjectName>.log" under Saved/Logs.
export function logName() {
if (process.env.UE_LOG_NAME) return process.env.UE_LOG_NAME;
const name = projectName();
return name ? `${name}.log` : "UnrealEditor.log";
}
// ---------------------------------------------------------------------------
// Engine discovery
// ---------------------------------------------------------------------------
const EPIC_ROOTS = [
"C:/Program Files/Epic Games",
"D:/Program Files/Epic Games",
"C:/Program Files (x86)/Epic Games",
];
let _engine = null;
// Returns the engine root (folder containing /Engine), or null if undetectable.
export function engineDir(override) {
if (override && fs.existsSync(override)) return norm(override);
if (_engine !== null) return _engine || null;
_engine = resolveEngine() || "";
return _engine || null;
}
function resolveEngine() {
// 1. Explicit override wins.
if (process.env.UE_ENGINE_DIR && fs.existsSync(process.env.UE_ENGINE_DIR)) {
return norm(process.env.UE_ENGINE_DIR);
}
const assoc = engineAssociation();
// 2. Installed-engine manifest (works on any Windows box, no registry).
const fromManifest = engineFromLauncherManifest(assoc);
if (fromManifest) return fromManifest;
// 3. Source-build registry mapping (GUID associations), Windows only.
if (assoc && /[0-9a-fA-F-]{20,}|\{.*\}/.test(assoc)) {
const fromReg = engineFromRegistry(assoc);
if (fromReg) return fromReg;
}
// 4. Scan the standard Epic install roots; prefer the version that matches
// EngineAssociation, else the highest installed version.
const scanned = scanEpicRoots(assoc);
if (scanned) return scanned;
return null;
}
function engineAssociation() {
const up = uprojectPath();
if (!up || !fs.existsSync(up)) return null;
try {
const json = JSON.parse(fs.readFileSync(up, "utf8"));
return json.EngineAssociation || null;
} catch { return null; }
}
// Parses LauncherInstalled.dat — JSON listing every Epic-installed engine with
// its AppName ("UE_5.7") and InstallLocation. Present on all launcher installs.
function engineFromLauncherManifest(assoc) {
const dat = "C:/ProgramData/Epic/UnrealEngineLauncher/LauncherInstalled.dat";
if (!fs.existsSync(dat)) return null;
let list;
try { list = JSON.parse(fs.readFileSync(dat, "utf8")).InstallationList || []; }
catch { return null; }
const engines = list.filter((e) => /^UE_/.test(e.AppName || ""));
if (!engines.length) return null;
if (assoc) {
const want = `UE_${assoc}`;
const hit = engines.find((e) => e.AppName === want);
if (hit && fs.existsSync(hit.InstallLocation)) return norm(hit.InstallLocation);
}
// Highest version available.
engines.sort((a, b) => verKey(b.AppName) - verKey(a.AppName));
for (const e of engines) if (fs.existsSync(e.InstallLocation)) return norm(e.InstallLocation);
return null;
}
// Source builds register a GUID->path map under HKCU\Software\Epic Games\Unreal Engine\Builds.
function engineFromRegistry(assoc) {
if (process.platform !== "win32") return null;
try {
const out = execFileSync(
"reg",
["query", "HKCU\\Software\\Epic Games\\Unreal Engine\\Builds", "/v", assoc.replace(/[{}]/g, "")],
{ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }
);
const m = /REG_SZ\s+(.+)$/m.exec(out);
if (m) {
const p = m[1].trim();
if (fs.existsSync(p)) return norm(p);
}
} catch { /* not a registered source build */ }
return null;
}
function scanEpicRoots(assoc) {
const candidates = [];
for (const root of EPIC_ROOTS) {
let dirs;
try { dirs = fs.readdirSync(root); } catch { continue; }
for (const d of dirs) {
if (!/^UE_/.test(d)) continue;
const full = path.join(root, d);
if (fs.existsSync(path.join(full, "Engine"))) candidates.push({ name: d, full });
}
}
if (!candidates.length) return null;
if (assoc) {
const hit = candidates.find((c) => c.name === `UE_${assoc}`);
if (hit) return norm(hit.full);
}
candidates.sort((a, b) => verKey(b.name) - verKey(a.name));
return norm(candidates[0].full);
}
function verKey(appName) {
const m = /UE_(\d+)\.(\d+)/.exec(appName || "");
if (!m) return 0;
return Number(m[1]) * 1000 + Number(m[2]);
}
// Locate UnrealInsights.exe: explicit env > resolved engine > scanned engines.
export function insightsExe() {
if (process.env.UE_INSIGHTS_EXE && fs.existsSync(process.env.UE_INSIGHTS_EXE)) {
return process.env.UE_INSIGHTS_EXE;
}
const eng = engineDir();
if (eng) {
const p = path.join(eng, "Engine/Binaries/Win64/UnrealInsights.exe");
if (fs.existsSync(p)) return norm(p);
}
// Fall back to scanning every installed engine root.
for (const root of EPIC_ROOTS) {
let dirs;
try { dirs = fs.readdirSync(root); } catch { continue; }
dirs.sort((a, b) => verKey(b) - verKey(a));
for (const d of dirs) {
const p = path.join(root, d, "Engine/Binaries/Win64/UnrealInsights.exe");
if (fs.existsSync(p)) return norm(p);
}
}
return null;
}

61
src/pythonGen.js Normal file
View File

@ -0,0 +1,61 @@
// Builds the single-shot Python command the MCP server sends to UE's
// PythonScriptLibrary.ExecutePythonCommand. The command embeds the graph as a
// JSON literal and dispatches to the in-engine helper module.
import path from "node:path";
import { fileURLToPath } from "node:url";
const HERE = path.dirname(fileURLToPath(import.meta.url));
export const UE_SIDE_DIR = path.resolve(HERE, "..", "ue_side");
/**
* Build a Python command string that, when executed inside UE, will:
* 1. Append our `ue_side/` to sys.path so the helper module is importable.
* 2. Call `apply_graph.entrypoint(<json>)`.
* 3. Print a result envelope `__MCP_RESULT__<json>__MCP_END__` so the server
* can parse stdout (UE returns ExecutePythonCommand output as a string).
*/
export function buildApplyCommand(graph, { ueSideDir = UE_SIDE_DIR } = {}) {
const payload = JSON.stringify(graph);
// Python string literal — JSON is conveniently a Python literal too, but we
// want to be safe against backslashes/quotes; embed via repr().
const pyPayload = pyRepr(payload);
const pyDir = pyRepr(ueSideDir.replace(/\\/g, "/"));
return [
"import sys, json",
`_d = ${pyDir}`,
"sys.path.insert(0, _d) if _d not in sys.path else None",
"import importlib, apply_graph",
"importlib.reload(apply_graph)",
`_out = apply_graph.entrypoint(${pyPayload})`,
"print('__MCP_RESULT__' + _out + '__MCP_END__')",
].join("\n");
}
export function parseResultEnvelope(stdout) {
const m = /__MCP_RESULT__([\s\S]*?)__MCP_END__/.exec(stdout || "");
if (!m) return { ok: false, errors: ["no result envelope in UE stdout"], raw: stdout };
try {
return JSON.parse(m[1]);
} catch (e) {
return { ok: false, errors: [`bad JSON in envelope: ${e.message}`], raw: m[1] };
}
}
function pyRepr(s) {
// Python-safe single-quoted string with \\, \', and control chars escaped.
let out = "'";
for (const ch of s) {
const c = ch.charCodeAt(0);
if (ch === "\\") out += "\\\\";
else if (ch === "'") out += "\\'";
else if (ch === "\n") out += "\\n";
else if (ch === "\r") out += "\\r";
else if (ch === "\t") out += "\\t";
else if (c < 0x20 || c === 0x7f) out += "\\x" + c.toString(16).padStart(2, "0");
else out += ch;
}
return out + "'";
}
export const _internal = { pyRepr };

1320
src/server.js Normal file

File diff suppressed because it is too large Load Diff

68
src/ueBridge.js Normal file
View File

@ -0,0 +1,68 @@
// Talks to UE's Remote Control HTTP server.
//
// Enable in UE: Edit > Plugins > "Remote Control API" (and "Python Editor
// Script Plugin"). Then `WebControl.EnableServerOnStartup = true` or run the
// console command `WebControl.StartServer` in the editor. Default port 30010.
const DEFAULT_BASE = process.env.UE_REMOTE_CONTROL_URL || "http://127.0.0.1:30010";
/**
* Execute an arbitrary Python command inside the UE editor.
* Returns { ok, stdout, stderr, status, error? }.
*/
export async function executePythonInUE(pythonCommand, { baseUrl = DEFAULT_BASE, timeoutMs = 30000 } = {}) {
const url = `${baseUrl}/remote/object/call`;
const body = {
objectPath: "/Script/PythonScriptPlugin.Default__PythonScriptLibrary",
functionName: "ExecutePythonCommand",
parameters: { PythonCommand: pythonCommand },
generateTransaction: true,
};
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
let resp;
try {
resp = await fetch(url, {
method: "PUT", // RC uses PUT for /remote/object/call
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal: ctrl.signal,
});
} catch (e) {
return { ok: false, error: `cannot reach UE Remote Control at ${baseUrl}: ${e.message}` };
} finally {
clearTimeout(timer);
}
const text = await resp.text();
if (!resp.ok) {
return { ok: false, status: resp.status, error: `RC HTTP ${resp.status}: ${text}` };
}
// RC returns { ReturnValue: bool, ... } — Python output isn't echoed back.
// For stdout capture we rely on the helper module printing into UE's
// OutputLog; the result envelope is parsed there too. Best we can do over
// REST is to wrap the helper to ALSO store its result in a global the
// server can read back with a second GET. Simpler: tee the result to a
// file in Saved/, then read it.
let parsed = null;
try { parsed = JSON.parse(text); } catch { /* ignore */ }
return { ok: true, status: resp.status, response: parsed ?? text };
}
/** Probe UE Remote Control liveness. Returns { ok, version? }. */
export async function ping({ baseUrl = DEFAULT_BASE, timeoutMs = 3000 } = {}) {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), timeoutMs);
try {
const r = await fetch(`${baseUrl}/remote/info`, { signal: ctrl.signal });
if (!r.ok) return { ok: false, error: `HTTP ${r.status}` };
const j = await r.json().catch(() => null);
return { ok: true, info: j };
} catch (e) {
return { ok: false, error: e.message };
} finally {
clearTimeout(t);
}
}

108
test/insights.test.js Normal file
View File

@ -0,0 +1,108 @@
// Structural tests for the Insights MCP layer.
// We synthesise minimal Insights-style TSVs in a tmp dir, run the ingester
// + analytics handlers directly, and assert the shapes. The full e2e path
// (UnrealInsights.exe export) is not exercised here — that needs a real
// .utrace and an installed Insights binary.
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { dbAvailable, openDb, ingestRawDir, listTables, tableColumns, parseTabular } from "../src/insightsDb.js";
function mkTmp() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "insights-mcp-test-"));
return dir;
}
function writeTsv(dir, name, header, rows) {
const text = [header.join("\t"), ...rows.map((r) => r.join("\t"))].join("\n");
fs.writeFileSync(path.join(dir, name), text);
}
test("parseTabular: TSV with mixed types", async () => {
const dir = mkTmp();
const raw = path.join(dir, "raw");
fs.mkdirSync(raw);
writeTsv(raw, "events.tsv",
["Name", "ThreadName", "StartTime", "Duration"],
[
["UWorld::Tick", "GameThread", "0.0", "0.008"],
["BPP_Foo::Tick", "GameThread", "0.008", "0.003"],
["FrameTime", "GameThread", "0.0", "0.016"],
["FrameTime", "GameThread", "0.016", "0.052"], // a hitch
]
);
const parsed = await parseTabular(path.join(raw, "events.tsv"));
assert.deepEqual(parsed.headers, ["Name", "ThreadName", "StartTime", "Duration"]);
assert.equal(parsed.types[2], "REAL");
assert.equal(parsed.rows.length, 4);
});
test("ingestRawDir: creates tables, picks indexes", async (t) => {
if (!dbAvailable()) return t.skip("better-sqlite3 not installed yet (run npm install)");
const dir = mkTmp();
const raw = path.join(dir, "raw");
fs.mkdirSync(raw);
writeTsv(raw, "events.tsv",
["Name", "ThreadName", "StartTime", "Duration"],
[
["UWorld::Tick", "GameThread", "0.0", "0.008"],
["FrameTime", "GameThread", "0.0", "0.016"],
["FrameTime", "GameThread", "0.016", "0.052"],
]
);
writeTsv(raw, "memtags.tsv",
["Name", "Peak", "Current"],
[["Niagara", "180000000", "120000000"], ["Audio", "40000000", "30000000"]]
);
const db = openDb(dir);
try {
const summary = await ingestRawDir(db, raw);
assert.ok(summary.find((s) => s.table === "events" && s.rows === 3));
assert.ok(summary.find((s) => s.table === "memtags" && s.rows === 2));
const tables = listTables(db);
assert.ok(tables.includes("events"));
assert.ok(tables.includes("memtags"));
const cols = tableColumns(db, "events").map((c) => c.name);
assert.deepEqual(cols, ["Name", "ThreadName", "StartTime", "Duration"]);
} finally { db.close(); }
});
test("handleFrameStats: detects hitches over threshold", async (t) => {
if (!dbAvailable()) return t.skip("better-sqlite3 not installed yet");
const { handleOpenTrace, handleFrameStats } = await import("../src/insights.js");
// We bypass open_trace's real-export by manually staging the cache
// structure that open_trace would have produced.
const projectDir = mkTmp();
const traceId = "abcd1234deadbeef";
const dir = path.join(projectDir, "Saved/InsightsMCP", traceId);
fs.mkdirSync(path.join(dir, "raw"), { recursive: true });
writeTsv(path.join(dir, "raw"), "events.tsv",
["Name", "ThreadName", "StartTime", "Duration"],
[
["FrameTime", "GameThread", "0.000", "0.016"],
["FrameTime", "GameThread", "0.016", "0.017"],
["FrameTime", "GameThread", "0.033", "0.052"], // hitch
["FrameTime", "GameThread", "0.085", "0.018"],
["FrameTime", "GameThread", "0.103", "0.045"], // hitch
]
);
const db = openDb(dir);
try { await ingestRawDir(db, path.join(dir, "raw")); } finally { db.close(); }
// Write a minimal trace index entry.
fs.writeFileSync(
path.join(projectDir, "Saved/InsightsMCP/traces.json"),
JSON.stringify({ [traceId]: { label: "synthetic", trace_path: "synthetic.utrace" } })
);
const res = await handleFrameStats({ trace_id: traceId, hitch_ms: 33, project_dir: projectDir });
assert.equal(res.isError, undefined, res.content?.[0]?.text);
const out = JSON.parse(res.content[0].text);
assert.equal(out.frames, 5);
assert.equal(out.hitch_count, 2);
assert.ok(out.worst_hitches[0].ms >= 50);
});

60
test/mockUE.test.js Normal file
View File

@ -0,0 +1,60 @@
// Spin up a fake Remote Control endpoint and verify the bridge talks to it.
import { test } from "node:test";
import assert from "node:assert/strict";
import http from "node:http";
import { executePythonInUE, ping } from "../src/ueBridge.js";
function startMockUE() {
const seen = { calls: [] };
const srv = http.createServer((req, res) => {
let body = "";
req.on("data", c => body += c);
req.on("end", () => {
if (req.url === "/remote/info" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
return res.end(JSON.stringify({ name: "MockUE", version: "5.7" }));
}
if (req.url === "/remote/object/call" && req.method === "PUT") {
let parsed = null;
try { parsed = JSON.parse(body); } catch {}
seen.calls.push(parsed);
res.writeHead(200, { "Content-Type": "application/json" });
return res.end(JSON.stringify({ ReturnValue: true }));
}
res.writeHead(404); res.end();
});
});
return new Promise(resolve => {
srv.listen(0, "127.0.0.1", () => {
const { port } = srv.address();
resolve({ srv, seen, baseUrl: `http://127.0.0.1:${port}` });
});
});
}
test("ping reaches mock UE", async () => {
const { srv, baseUrl } = await startMockUE();
try {
const r = await ping({ baseUrl, timeoutMs: 2000 });
assert.equal(r.ok, true);
assert.equal(r.info.name, "MockUE");
} finally { srv.close(); }
});
test("executePythonInUE PUTs the right payload to /remote/object/call", async () => {
const { srv, seen, baseUrl } = await startMockUE();
try {
const r = await executePythonInUE("print('hi')", { baseUrl, timeoutMs: 2000 });
assert.equal(r.ok, true);
assert.equal(seen.calls.length, 1);
assert.equal(seen.calls[0].functionName, "ExecutePythonCommand");
assert.equal(seen.calls[0].parameters.PythonCommand, "print('hi')");
assert.ok(seen.calls[0].objectPath.includes("PythonScriptLibrary"));
} finally { srv.close(); }
});
test("executePythonInUE returns ok:false when UE unreachable", async () => {
const r = await executePythonInUE("print('x')", { baseUrl: "http://127.0.0.1:1", timeoutMs: 500 });
assert.equal(r.ok, false);
assert.match(r.error, /cannot reach UE/);
});

70
test/nodeSpec.test.js Normal file
View File

@ -0,0 +1,70 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { validateGraph, NODE_KINDS } from "../src/nodeSpec.js";
test("validateGraph allows missing bp_path (auto-detect mode)", () => {
const errs = validateGraph({ nodes: [] });
assert.deepEqual(errs, []);
});
test("validateGraph rejects bp_path not under /Game/ when provided", () => {
const errs = validateGraph({ bp_path: "/Engine/Foo", nodes: [] });
assert.ok(errs.some(e => e.includes("bp_path")));
});
test("validateGraph accepts empty-string bp_path as auto-detect", () => {
const errs = validateGraph({ bp_path: "", nodes: [] });
assert.deepEqual(errs, []);
});
test("validateGraph accepts a minimal valid graph", () => {
const errs = validateGraph({
bp_path: "/Game/Test/BP_Foo",
nodes: [{ id: "n1", kind: "branch" }],
edges: [],
});
assert.deepEqual(errs, []);
});
test("validateGraph catches duplicate ids", () => {
const errs = validateGraph({
bp_path: "/Game/X",
nodes: [
{ id: "a", kind: "branch" },
{ id: "a", kind: "sequence" },
],
});
assert.ok(errs.some(e => e.includes("duplicate")));
});
test("validateGraph catches unknown kind", () => {
const errs = validateGraph({
bp_path: "/Game/X",
nodes: [{ id: "a", kind: "teleport_to_mars" }],
});
assert.ok(errs.some(e => e.includes("teleport_to_mars")));
});
test("validateGraph catches edge to unknown node", () => {
const errs = validateGraph({
bp_path: "/Game/X",
nodes: [{ id: "a", kind: "branch" }],
edges: [{ from: ["a", "Then"], to: ["ghost", "Exec"] }],
});
assert.ok(errs.some(e => e.includes("ghost")));
});
test("NODE_KINDS contains the documented set", () => {
for (const k of [
"call_function", "event", "custom_event", "component_event",
"variable_get", "variable_set", "self",
"branch", "sequence",
"switch_int", "switch_string", "switch_enum",
"macro", "cast",
"make_array", "make_struct", "break_struct", "set_fields_in_struct",
"spawn_actor", "format_text", "get_subsystem", "load_asset",
"bind_delegate", "unbind_delegate", "call_delegate", "assign_delegate",
]) {
assert.ok(NODE_KINDS.has(k), `missing kind: ${k}`);
}
});

35
test/pythonGen.test.js Normal file
View File

@ -0,0 +1,35 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { buildApplyCommand, parseResultEnvelope, _internal } from "../src/pythonGen.js";
test("pyRepr escapes quotes, backslashes and newlines", () => {
const r = _internal.pyRepr(`a'b\\c\nd`);
assert.equal(r, `'a\\'b\\\\c\\nd'`);
});
test("buildApplyCommand emits importable bootstrap and embeds payload", () => {
const graph = { bp_path: "/Game/X", nodes: [{ id: "n1", kind: "branch" }] };
const cmd = buildApplyCommand(graph);
assert.match(cmd, /import sys, json/);
assert.match(cmd, /apply_graph\.entrypoint\(/);
assert.match(cmd, /__MCP_RESULT__/);
// Payload must survive as a python literal that, when eval'd, equals our JSON.
const m = /apply_graph\.entrypoint\(('.*?')\)/s.exec(cmd);
assert.ok(m, "no payload found in command");
// Roughly verify: drop quotes & unescape single-quote/backslash and compare JSON.
const lit = m[1].slice(1, -1).replace(/\\'/g, "'").replace(/\\\\/g, "\\")
.replace(/\\n/g, "\n").replace(/\\r/g, "\r").replace(/\\t/g, "\t");
assert.deepEqual(JSON.parse(lit), graph);
});
test("parseResultEnvelope extracts JSON between markers", () => {
const stdout = "noise\n__MCP_RESULT__" + JSON.stringify({ ok: true, spawned: { a: "K2N_1" } }) + "__MCP_END__\nmore noise";
const r = parseResultEnvelope(stdout);
assert.equal(r.ok, true);
assert.equal(r.spawned.a, "K2N_1");
});
test("parseResultEnvelope errors on missing envelope", () => {
const r = parseResultEnvelope("no envelope here");
assert.equal(r.ok, false);
});

110
test/server.test.js Normal file
View File

@ -0,0 +1,110 @@
// End-to-end smoke: spawn the MCP server as a subprocess, speak the wire
// protocol over stdio, verify tools/list and a dry_run apply_graph.
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import path from "node:path";
import { fileURLToPath } from "node:url";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const SERVER = path.resolve(HERE, "..", "src", "server.js");
function startServer() {
const proc = spawn(process.execPath, [SERVER], { stdio: ["pipe", "pipe", "pipe"] });
let buf = "";
const pending = new Map();
let nextId = 1;
proc.stdout.on("data", chunk => {
buf += chunk.toString();
let idx;
while ((idx = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, idx); buf = buf.slice(idx + 1);
if (!line.trim()) continue;
try {
const msg = JSON.parse(line);
if (msg.id && pending.has(msg.id)) {
const { resolve } = pending.get(msg.id);
pending.delete(msg.id);
resolve(msg);
}
} catch { /* ignore non-JSON banner */ }
}
});
proc.stderr.on("data", () => { /* swallow */ });
function send(method, params) {
const id = nextId++;
const msg = { jsonrpc: "2.0", id, method, params };
return new Promise((resolve, reject) => {
pending.set(id, { resolve, reject });
proc.stdin.write(JSON.stringify(msg) + "\n");
setTimeout(() => {
if (pending.has(id)) { pending.delete(id); reject(new Error(`timeout: ${method}`)); }
}, 5000);
});
}
function close() { try { proc.kill(); } catch {} }
return { send, close };
}
async function initialize(send) {
return send("initialize", {
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: { name: "test", version: "0" },
});
}
test("MCP server lists tools over stdio", async () => {
const { send, close } = startServer();
try {
await initialize(send);
const r = await send("tools/list", {});
const names = r.result.tools.map(t => t.name);
assert.ok(names.includes("apply_graph"));
assert.ok(names.includes("introspect"));
assert.ok(names.includes("compile"));
assert.ok(names.includes("read_graph"));
assert.ok(names.includes("ping_ue"));
assert.ok(names.includes("selection_op"));
assert.ok(names.includes("voxel_graph_op"));
assert.ok(names.includes("material_op"));
} finally { close(); }
});
test("apply_graph dry_run returns python without calling UE", async () => {
const { send, close } = startServer();
try {
await initialize(send);
const r = await send("tools/call", {
name: "apply_graph",
arguments: {
bp_path: "/Game/Test/BP_Foo",
nodes: [
{ id: "ev", kind: "event", target: "BeginPlay", position: [0, 0] },
{ id: "br", kind: "branch", position: [400, 0] },
],
edges: [{ from: ["ev", "Then"], to: ["br", "Exec"] }],
dry_run: true,
},
});
assert.equal(r.result.isError ?? false, false);
const payload = JSON.parse(r.result.content[0].text);
assert.equal(payload.dry_run, true);
assert.match(payload.python, /apply_graph\.entrypoint/);
assert.match(payload.python, /BeginPlay/);
} finally { close(); }
});
test("apply_graph rejects invalid graphs", async () => {
const { send, close } = startServer();
try {
await initialize(send);
const r = await send("tools/call", {
name: "apply_graph",
arguments: { bp_path: "not-a-game-path", nodes: [] },
});
assert.equal(r.result.isError, true);
assert.match(r.result.content[0].text, /bp_path/);
} finally { close(); }
});

215
ue_side/ai.py Normal file
View File

@ -0,0 +1,215 @@
"""AI ops — BehaviorTree, Blackboard, EQS, AIController.
Ops:
create_behavior_tree {path, blackboard?}
create_blackboard {path, parent?}
create_eqs_query {path}
create_aicontroller_bp {path, parent_class?:'AIController'}
add_blackboard_key {path, name, type:'Bool|Int|Float|String|Name|Vector|Rotator|Object|Class|Enum', base_class?}
remove_blackboard_key {path, name}
list_blackboard_keys {path}
set_bt_blackboard {bt_path, blackboard_path}
"""
from __future__ import annotations
import json
import os
import traceback
try:
import unreal # type: ignore
except ImportError:
unreal = None
def _at(): return unreal.AssetToolsHelpers.get_asset_tools()
def _load(p): return unreal.EditorAssetLibrary.load_asset(p)
def _split(p):
folder, name = p.rsplit("/", 1)
return folder, name
KEY_TYPES = {
"Bool": "BlackboardKeyType_Bool",
"Int": "BlackboardKeyType_Int",
"Float": "BlackboardKeyType_Float",
"String": "BlackboardKeyType_String",
"Name": "BlackboardKeyType_Name",
"Vector": "BlackboardKeyType_Vector",
"Rotator": "BlackboardKeyType_Rotator",
"Object": "BlackboardKeyType_Object",
"Class": "BlackboardKeyType_Class",
"Enum": "BlackboardKeyType_Enum",
}
def create_behavior_tree(args):
folder, name = _split(args["path"])
factory_cls = getattr(unreal, "BehaviorTreeFactory", None)
if factory_cls is None:
return {"ok": False, "error": "BehaviorTreeFactory missing — enable AIModule"}
asset = _at().create_asset(name, folder, unreal.BehaviorTree, factory_cls())
if asset is None:
return {"ok": False, "error": "create returned None"}
if args.get("blackboard"):
bb = _load(args["blackboard"])
if bb is not None:
asset.set_editor_property("blackboard_asset", bb)
unreal.EditorAssetLibrary.save_asset(asset.get_path_name())
return {"ok": True, "path": asset.get_path_name().split(".")[0]}
def create_blackboard(args):
folder, name = _split(args["path"])
factory_cls = getattr(unreal, "BlackboardDataFactory", None)
if factory_cls is None:
return {"ok": False, "error": "BlackboardDataFactory missing"}
asset = _at().create_asset(name, folder, unreal.BlackboardData, factory_cls())
if asset is None:
return {"ok": False, "error": "create returned None"}
if args.get("parent"):
parent = _load(args["parent"])
if parent is not None:
try:
asset.set_editor_property("parent", parent)
except Exception:
pass
unreal.EditorAssetLibrary.save_asset(asset.get_path_name())
return {"ok": True, "path": asset.get_path_name().split(".")[0]}
def create_eqs_query(args):
folder, name = _split(args["path"])
factory_cls = getattr(unreal, "EnvQueryFactory", None)
asset_cls = getattr(unreal, "EnvQuery", None)
if factory_cls is None or asset_cls is None:
return {"ok": False, "error": "EnvQuery factory missing — enable EQS in project settings"}
asset = _at().create_asset(name, folder, asset_cls, factory_cls())
if asset is None:
return {"ok": False, "error": "create returned None"}
unreal.EditorAssetLibrary.save_asset(asset.get_path_name())
return {"ok": True, "path": asset.get_path_name().split(".")[0]}
def create_aicontroller_bp(args):
folder, name = _split(args["path"])
parent_name = args.get("parent_class") or "AIController"
parent = unreal.load_class(None, "/Script/AIModule." + parent_name)
if parent is None:
parent = unreal.load_class(None, parent_name)
if parent is None:
return {"ok": False, "error": f"parent {parent_name!r} not found"}
factory = unreal.BlueprintFactory()
factory.set_editor_property("parent_class", parent)
asset = _at().create_asset(name, folder, unreal.Blueprint, factory)
if asset is None:
return {"ok": False, "error": "create returned None"}
unreal.EditorAssetLibrary.save_asset(asset.get_path_name())
return {"ok": True, "path": asset.get_path_name().split(".")[0]}
def add_blackboard_key(args):
bb = _load(args["path"])
if bb is None:
return {"ok": False, "error": "blackboard not found"}
type_name = KEY_TYPES.get(args["type"])
if not type_name:
return {"ok": False, "error": f"unknown key type {args['type']!r}; valid: {list(KEY_TYPES)}"}
key_type_cls = getattr(unreal, type_name, None)
if key_type_cls is None:
return {"ok": False, "error": f"{type_name} not exposed"}
entry_cls = getattr(unreal, "BlackboardEntry", None)
if entry_cls is None:
return {"ok": False, "error": "BlackboardEntry not exposed; edit via UI"}
entry = entry_cls()
entry.set_editor_property("entry_name", args["name"])
kt = key_type_cls()
if args.get("base_class") and hasattr(kt, "base_class"):
cls = unreal.load_class(None, args["base_class"])
if cls is not None:
kt.set_editor_property("base_class", cls)
entry.set_editor_property("key_type", kt)
keys = list(bb.get_editor_property("keys") or [])
keys.append(entry)
bb.set_editor_property("keys", keys)
unreal.EditorAssetLibrary.save_asset(bb.get_path_name())
return {"ok": True, "name": args["name"]}
def remove_blackboard_key(args):
bb = _load(args["path"])
if bb is None:
return {"ok": False, "error": "blackboard not found"}
target = args["name"]
keys = [k for k in (bb.get_editor_property("keys") or [])
if str(k.get_editor_property("entry_name")) != target]
bb.set_editor_property("keys", keys)
unreal.EditorAssetLibrary.save_asset(bb.get_path_name())
return {"ok": True, "removed": target}
def list_blackboard_keys(args):
bb = _load(args["path"])
if bb is None:
return {"ok": False, "error": "blackboard not found"}
out = []
for k in (bb.get_editor_property("keys") or []):
try:
kt = k.get_editor_property("key_type")
out.append({"name": str(k.get_editor_property("entry_name")),
"type": kt.get_class().get_name() if kt else None})
except Exception:
pass
return {"ok": True, "keys": out}
def set_bt_blackboard(args):
bt = _load(args["bt_path"])
bb = _load(args["blackboard_path"])
if bt is None or bb is None:
return {"ok": False, "error": "bt or blackboard not found"}
bt.set_editor_property("blackboard_asset", bb)
unreal.EditorAssetLibrary.save_asset(bt.get_path_name())
return {"ok": True}
OPS = {
"create_behavior_tree": create_behavior_tree,
"create_blackboard": create_blackboard,
"create_eqs_query": create_eqs_query,
"create_aicontroller_bp": create_aicontroller_bp,
"add_blackboard_key": add_blackboard_key,
"remove_blackboard_key": remove_blackboard_key,
"list_blackboard_keys": list_blackboard_keys,
"set_bt_blackboard": set_bt_blackboard,
}
def entrypoint(payload_json):
try:
p = json.loads(payload_json)
except Exception as e:
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
op = p.get("op"); fn = OPS.get(op)
if not fn:
return _tee({"ok": False, "error": f"unknown op {op!r}", "available": list(OPS)}, p)
try:
return _tee(fn(p), p)
except Exception as e:
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
def _tee(result, p):
txt = json.dumps(result)
try:
if unreal is not None:
sd = unreal.Paths.project_saved_dir() + "MCP/"
os.makedirs(sd, exist_ok=True)
rid = p.get("__rid") or "last"
with open(sd + "last.json", "w", encoding="utf-8") as f: f.write(txt)
if rid != "last":
with open(sd + f"{rid}.json", "w", encoding="utf-8") as f: f.write(txt)
unreal.log("[MCP-AI] " + txt[:1500])
except Exception:
pass
return txt

386
ue_side/animation.py Normal file
View File

@ -0,0 +1,386 @@
"""Animation ops — AnimBlueprint, AnimSequence, AnimMontage, Notifies, Skeleton sockets.
Ops:
create_anim_blueprint {path, skeleton}
create_anim_montage {path, sequence?}
create_blend_space {path, skeleton, dim?:1|2}
get_sequence_info {path} — length, fps, frames, additive type, skeleton
list_anim_assets {folder?, skeleton?}
list_sockets {skeleton}
add_socket {skeleton, name, bone, location?, rotation?}
remove_socket {skeleton, name}
list_montage_sections {path}
add_montage_section {path, name, start_time?}
list_notifies {sequence}
add_notify {sequence, name, time, notify_class?}
remove_notify {sequence, index}
list_state_machines {anim_bp} — best-effort (graph names)
retarget_animation {src, dst_skeleton, dst_path}
"""
from __future__ import annotations
import json
import os
import traceback
try:
import unreal # type: ignore
except ImportError:
unreal = None
def _at():
return unreal.AssetToolsHelpers.get_asset_tools()
def _load(path: str):
return unreal.EditorAssetLibrary.load_asset(path)
def _split_pkg(path: str):
folder, name = path.rsplit("/", 1)
return folder, name
def _vec(v, default=(0, 0, 0)):
if v is None:
v = default
return unreal.Vector(float(v[0]), float(v[1]), float(v[2]))
def _rot(v, default=(0, 0, 0)):
if v is None:
v = default
return unreal.Rotator(float(v[0]), float(v[1]), float(v[2]))
def create_anim_blueprint(args: dict) -> dict:
skel = _load(args["skeleton"])
if skel is None:
return {"ok": False, "error": f"skeleton not found: {args['skeleton']}"}
folder, name = _split_pkg(args["path"])
factory = unreal.AnimBlueprintFactory()
factory.set_editor_property("target_skeleton", skel)
asset = _at().create_asset(name, folder, unreal.AnimBlueprint, factory)
if asset is None:
return {"ok": False, "error": "create_asset returned None"}
unreal.EditorAssetLibrary.save_asset(asset.get_path_name())
return {"ok": True, "path": asset.get_path_name().split(".")[0]}
def create_anim_montage(args: dict) -> dict:
folder, name = _split_pkg(args["path"])
factory = unreal.AnimMontageFactory()
seq_path = args.get("sequence")
if seq_path:
seq = _load(seq_path)
if seq is None:
return {"ok": False, "error": f"sequence not found: {seq_path}"}
if hasattr(factory, "set_editor_property"):
try:
factory.set_editor_property("preview_skeletal_mesh", seq.get_editor_property("skeleton"))
except Exception:
pass
asset = _at().create_asset(name, folder, unreal.AnimMontage, factory)
if asset is None:
return {"ok": False, "error": "create_asset returned None"}
return {"ok": True, "path": asset.get_path_name().split(".")[0]}
def create_blend_space(args: dict) -> dict:
skel = _load(args["skeleton"])
if skel is None:
return {"ok": False, "error": "skeleton not found"}
folder, name = _split_pkg(args["path"])
dim = int(args.get("dim") or 2)
factory_cls = getattr(unreal, "BlendSpaceFactory1D" if dim == 1 else "BlendSpaceFactoryNew", None)
if factory_cls is None:
return {"ok": False, "error": "BlendSpace factory unavailable in this UE build"}
factory = factory_cls()
if hasattr(factory, "target_skeleton"):
factory.target_skeleton = skel
asset_cls = unreal.BlendSpace1D if dim == 1 else unreal.BlendSpace
asset = _at().create_asset(name, folder, asset_cls, factory)
if asset is None:
return {"ok": False, "error": "create_asset returned None"}
return {"ok": True, "path": asset.get_path_name().split(".")[0]}
def get_sequence_info(args: dict) -> dict:
seq = _load(args["path"])
if seq is None:
return {"ok": False, "error": "not found"}
out = {"ok": True, "path": args["path"], "class": seq.get_class().get_name()}
for attr in ("get_play_length", "sequence_length"):
try:
v = getattr(seq, attr)
out["length"] = v() if callable(v) else float(v)
break
except Exception:
pass
try:
out["num_frames"] = int(seq.get_editor_property("number_of_sampled_keys") or 0)
except Exception:
pass
try:
out["frame_rate"] = float(seq.get_editor_property("target_frame_rate") or 0)
except Exception:
pass
try:
skel = seq.get_editor_property("skeleton")
if skel:
out["skeleton"] = skel.get_path_name().split(".")[0]
except Exception:
pass
try:
out["additive_type"] = str(seq.get_editor_property("additive_anim_type"))
except Exception:
pass
return out
def list_anim_assets(args: dict) -> dict:
folder = args.get("folder") or "/Game"
skel_filter = args.get("skeleton")
ar = unreal.AssetRegistryHelpers.get_asset_registry()
filt = unreal.ARFilter(package_paths=[folder], recursive_paths=True,
class_names=["AnimSequence", "AnimMontage", "BlendSpace", "BlendSpace1D", "AnimBlueprint"])
items = []
for d in ar.get_assets(filt):
path = str(d.package_name)
if skel_filter:
try:
asset = _load(path)
sk = asset.get_editor_property("skeleton") if asset else None
if not sk or sk.get_path_name().split(".")[0] != skel_filter:
continue
except Exception:
continue
items.append({"path": path, "class": str(d.asset_class)})
return {"ok": True, "count": len(items), "items": items}
def list_sockets(args: dict) -> dict:
skel = _load(args["skeleton"])
if skel is None:
return {"ok": False, "error": "skeleton not found"}
try:
sockets = skel.get_editor_property("sockets") or []
except Exception:
sockets = []
out = []
for s in sockets:
try:
out.append({
"name": str(s.get_editor_property("socket_name")),
"bone": str(s.get_editor_property("bone_name")),
"location": list(s.get_editor_property("relative_location")),
"rotation": list(s.get_editor_property("relative_rotation")),
})
except Exception:
pass
return {"ok": True, "count": len(out), "sockets": out}
def add_socket(args: dict) -> dict:
skel = _load(args["skeleton"])
if skel is None:
return {"ok": False, "error": "skeleton not found"}
sock = unreal.SkeletalMeshSocket()
sock.set_editor_property("socket_name", args["name"])
sock.set_editor_property("bone_name", args["bone"])
sock.set_editor_property("relative_location", _vec(args.get("location")))
sock.set_editor_property("relative_rotation", _rot(args.get("rotation")))
sockets = list(skel.get_editor_property("sockets") or [])
sockets.append(sock)
skel.set_editor_property("sockets", sockets)
unreal.EditorAssetLibrary.save_asset(skel.get_path_name())
return {"ok": True, "name": args["name"]}
def remove_socket(args: dict) -> dict:
skel = _load(args["skeleton"])
if skel is None:
return {"ok": False, "error": "skeleton not found"}
target = args["name"]
sockets = [s for s in (skel.get_editor_property("sockets") or [])
if str(s.get_editor_property("socket_name")) != target]
skel.set_editor_property("sockets", sockets)
unreal.EditorAssetLibrary.save_asset(skel.get_path_name())
return {"ok": True, "removed": target}
def list_montage_sections(args: dict) -> dict:
m = _load(args["path"])
if m is None:
return {"ok": False, "error": "not found"}
try:
secs = m.get_editor_property("composite_sections") or []
except Exception:
secs = []
out = []
for s in secs:
try:
out.append({"name": str(s.get_editor_property("section_name")),
"start_time": float(s.get_editor_property("segment_begin_time") or 0)})
except Exception:
pass
return {"ok": True, "sections": out}
def add_montage_section(args: dict) -> dict:
m = _load(args["path"])
if m is None:
return {"ok": False, "error": "not found"}
sec = unreal.CompositeSection() if hasattr(unreal, "CompositeSection") else None
if sec is None:
return {"ok": False, "error": "CompositeSection not exposed in this UE build"}
sec.set_editor_property("section_name", args["name"])
sec.set_editor_property("segment_begin_time", float(args.get("start_time") or 0))
secs = list(m.get_editor_property("composite_sections") or [])
secs.append(sec)
m.set_editor_property("composite_sections", secs)
unreal.EditorAssetLibrary.save_asset(m.get_path_name())
return {"ok": True}
def list_notifies(args: dict) -> dict:
seq = _load(args["sequence"])
if seq is None:
return {"ok": False, "error": "not found"}
try:
notifies = seq.get_editor_property("notifies") or []
except Exception:
notifies = []
out = []
for n in notifies:
try:
cls = n.get_editor_property("notify")
out.append({
"name": str(n.get_editor_property("notify_name")),
"time": float(n.get_editor_property("trigger_time_offset") or 0) + float(n.get_editor_property("link_value") or 0),
"class": cls.get_class().get_name() if cls else None,
})
except Exception:
pass
return {"ok": True, "notifies": out}
def add_notify(args: dict) -> dict:
seq = _load(args["sequence"])
if seq is None:
return {"ok": False, "error": "not found"}
ev = unreal.AnimNotifyEvent() if hasattr(unreal, "AnimNotifyEvent") else None
if ev is None:
return {"ok": False, "error": "AnimNotifyEvent not exposed; use editor UI"}
ev.set_editor_property("notify_name", args["name"])
ev.set_editor_property("link_value", float(args["time"]))
notifies = list(seq.get_editor_property("notifies") or [])
notifies.append(ev)
seq.set_editor_property("notifies", notifies)
unreal.EditorAssetLibrary.save_asset(seq.get_path_name())
return {"ok": True}
def remove_notify(args: dict) -> dict:
seq = _load(args["sequence"])
if seq is None:
return {"ok": False, "error": "not found"}
notifies = list(seq.get_editor_property("notifies") or [])
idx = int(args["index"])
if idx < 0 or idx >= len(notifies):
return {"ok": False, "error": "index out of range"}
notifies.pop(idx)
seq.set_editor_property("notifies", notifies)
unreal.EditorAssetLibrary.save_asset(seq.get_path_name())
return {"ok": True}
def list_state_machines(args: dict) -> dict:
bp = _load(args["anim_bp"])
if bp is None:
return {"ok": False, "error": "not found"}
out = []
try:
for g in bp.get_editor_property("ubergraph_pages") or []:
out.append(g.get_name())
except Exception:
pass
try:
for g in bp.get_editor_property("function_graphs") or []:
out.append(g.get_name())
except Exception:
pass
return {"ok": True, "graphs": out}
def retarget_animation(args: dict) -> dict:
src = _load(args["src"])
dst_skel = _load(args["dst_skeleton"])
if src is None or dst_skel is None:
return {"ok": False, "error": "src or dst_skeleton not found"}
folder, name = _split_pkg(args["dst_path"])
try:
new_asset = unreal.AnimationLibrary.duplicate_animation_asset(src, name, folder) \
if hasattr(unreal, "AnimationLibrary") and hasattr(unreal.AnimationLibrary, "duplicate_animation_asset") \
else unreal.EditorAssetLibrary.duplicate_asset(src.get_path_name(), folder + "/" + name)
except Exception as e:
return {"ok": False, "error": f"duplicate failed: {e}"}
try:
new_asset.set_editor_property("skeleton", dst_skel)
except Exception:
pass
unreal.EditorAssetLibrary.save_asset(new_asset.get_path_name())
return {"ok": True, "path": new_asset.get_path_name().split(".")[0]}
OPS = {
"create_anim_blueprint": create_anim_blueprint,
"create_anim_montage": create_anim_montage,
"create_blend_space": create_blend_space,
"get_sequence_info": get_sequence_info,
"list_anim_assets": list_anim_assets,
"list_sockets": list_sockets,
"add_socket": add_socket,
"remove_socket": remove_socket,
"list_montage_sections": list_montage_sections,
"add_montage_section": add_montage_section,
"list_notifies": list_notifies,
"add_notify": add_notify,
"remove_notify": remove_notify,
"list_state_machines": list_state_machines,
"retarget_animation": retarget_animation,
}
def entrypoint(payload_json: str) -> str:
try:
p = json.loads(payload_json)
except Exception as e:
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
op = p.get("op")
fn = OPS.get(op)
if not fn:
return _tee({"ok": False, "error": f"unknown op {op!r}", "available": list(OPS)}, p)
try:
return _tee(fn(p), p)
except Exception as e:
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
def _tee(result: dict, p: dict) -> str:
txt = json.dumps(result)
try:
if unreal is not None:
sd = unreal.Paths.project_saved_dir() + "MCP/"
os.makedirs(sd, exist_ok=True)
rid = p.get("__rid") or "last"
with open(sd + "last.json", "w", encoding="utf-8") as f:
f.write(txt)
if rid != "last":
with open(sd + f"{rid}.json", "w", encoding="utf-8") as f:
f.write(txt)
unreal.log("[MCP-ANIM] " + txt[:1500])
except Exception:
pass
return txt

519
ue_side/apply_graph.py Normal file
View File

@ -0,0 +1,519 @@
"""
UE-side runtime for the Blueprint MCP server.
Reaches into the editor via `unreal.MCPGraphLibrary` — a C++ BlueprintFunction
library provided by Plugins/UEBlueprintMCP/Source. That library exposes K2 node
spawning, which is NOT available in stock UE Python.
Flow per apply():
1. Resolve target Blueprint (by path, or via Content Browser selection).
2. Resolve target graph (EventGraph by default, else named function).
3. For each NodeSpec — call the matching MCPGraphLibrary spawn function and
remember NodeGuid keyed by local id.
4. Set pin defaults (params).
5. For each EdgeSpec — call ConnectPins using NodeGuids.
6. Compile + save Blueprint.
7. Write the result envelope to <Project>/Saved/MCP/last.json and unreal.log.
"""
from __future__ import annotations
import json
import os
import traceback
import graph_layout
try:
import unreal # type: ignore
except ImportError: # pragma: no cover — tests run outside UE
unreal = None
def _lib():
"""Return the MCPGraphLibrary class, or raise if the plugin isn't built."""
L = getattr(unreal, "MCPGraphLibrary", None)
if L is None:
raise RuntimeError(
"unreal.MCPGraphLibrary not found — the UEBlueprintMCP C++ plugin "
"is not built. Right-click your .uproject → Generate VS project files, "
"then build the editor."
)
return L
# ---------------------------------------------------------------------------
# Blueprint / graph resolution
# ---------------------------------------------------------------------------
def _load_blueprint(path: str):
asset = unreal.EditorAssetLibrary.load_asset(path)
if asset is None:
raise RuntimeError(f"Blueprint not found at {path}")
if not isinstance(asset, unreal.Blueprint):
raise RuntimeError(f"Asset at {path} is not a Blueprint ({type(asset).__name__})")
return asset
def _active_blueprint():
"""Return (Blueprint, path) from Content Browser selection."""
selected = []
try:
selected = list(unreal.EditorUtilityLibrary.get_selected_assets() or [])
except Exception:
selected = []
bps = [a for a in selected if isinstance(a, unreal.Blueprint)]
if len(bps) == 1:
bp = bps[0]
return bp, bp.get_path_name().split(".")[0]
if len(bps) > 1:
raise RuntimeError(f"{len(bps)} Blueprints selected — select exactly one or pass bp_path")
raise RuntimeError(
"auto-detect needs one Blueprint selected in Content Browser. "
"Click your BP asset and retry, or pass bp_path explicitly."
)
def _find_graph(bp, function_name: str):
L = _lib()
if function_name in (None, "", "EventGraph"):
g = L.find_event_graph(bp)
if g is None:
raise RuntimeError("Blueprint has no EventGraph")
return g
g = L.find_function_graph(bp, function_name)
if g is None:
raise RuntimeError(f"Graph '{function_name}' not found on Blueprint")
return g
# ---------------------------------------------------------------------------
# Function target parsing
# ---------------------------------------------------------------------------
def _resolve_function_owner(target: str):
"""Parse 'KismetSystemLibrary.PrintString' or '/Script/Engine.KismetSystemLibrary:PrintString'.
Returns (UClass, FName-able str).
"""
if ":" in target:
cls_path, fn = target.split(":", 1)
elif "." in target:
cls_path, fn = target.rsplit(".", 1)
else:
raise RuntimeError(f"call_function target must be Class.Function, got {target!r}")
cls = None
if "/" in cls_path:
cls = unreal.load_class(None, cls_path)
else:
for prefix in ("/Script/Engine.", "/Script/CoreUObject.", "/Script/UMG.",
"/Script/GameplayTags.", "/Script/UnrealEd.", "/Script/Kismet."):
cls = unreal.load_class(None, prefix + cls_path)
if cls is not None:
break
if cls is None:
raise RuntimeError(f"Class not found for target {target!r}")
return cls, fn
# ---------------------------------------------------------------------------
# Apply
# ---------------------------------------------------------------------------
def apply(graph: dict) -> dict:
result = {
"ok": False, "spawned": {}, "node_guids": {},
"errors": [], "warnings": [], "compile": None,
}
if unreal is None:
result["errors"].append("unreal module not importable — must run inside UE editor")
return result
try:
L = _lib()
bp_path = graph.get("bp_path") or ""
if bp_path:
bp = _load_blueprint(bp_path)
result["resolved_bp"] = bp_path
else:
bp, bp_path = _active_blueprint()
result["resolved_bp"] = bp_path
result["auto_detected"] = True
function_name = graph.get("function") or "EventGraph"
target_graph = _find_graph(bp, function_name)
# ---- mode handling ----
mode = (graph.get("mode") or "append").lower()
if mode in ("replace_function", "replace_event"):
try:
cleared = L.clear_graph(target_graph)
result["cleared_nodes"] = int(cleared)
except Exception as e: # noqa: BLE001
result["warnings"].append(f"clear_graph failed: {e}")
layout = str(graph.get("layout") or "").lower()
auto_layout = bool(graph.get("auto_layout", False))
should_layout = auto_layout or layout in ("compact", "columns")
node_specs = graph_layout.normalize_specs(
graph.get("nodes", []),
graph.get("edges", []),
domain="blueprint",
) if should_layout else list(graph.get("nodes", []))
# ---- spawn nodes ----
guid_map: dict[str, str] = {}
for spec in node_specs:
try:
guid = _spawn_one(target_graph, spec, bp, L)
if not guid:
raise RuntimeError("spawn returned empty guid")
guid_map[spec["id"]] = guid
result["spawned"][spec["id"]] = guid
except Exception as e: # noqa: BLE001
result["errors"].append(f"node {spec.get('id')!r}: {e}")
# ---- post-spawn config (switch cases, format text, sequence pins are spawn-time) ----
for spec in node_specs:
local_id = spec.get("id")
guid = guid_map.get(local_id)
if not guid:
continue
p = spec.get("params") or {}
kind = spec.get("kind")
# FormatText: set Format & reconstruct so {arg} pins appear
if kind == "format_text" and "Format" in p:
try:
L.format_text_set_format(target_graph, guid, str(p["Format"]))
except Exception as e: # noqa: BLE001
result["warnings"].append(f"format_text_set_format: {local_id}: {e}")
# Switch nodes: add case pins
if kind in ("switch_int", "switch_string", "switch_enum"):
cases = int(p.get("cases", 0))
for _ in range(cases):
try:
L.add_case_pin_to_switch(target_graph, guid)
except Exception as e: # noqa: BLE001
result["warnings"].append(f"add_case_pin_to_switch: {local_id}: {e}")
# ---- set pin defaults ----
SKIP_PARAMS = {"cases", "then_count", "width", "height", "text", "Format"}
for spec in node_specs:
local_id = spec.get("id")
guid = guid_map.get(local_id)
if not guid:
continue
for pin_name, value in (spec.get("params") or {}).items():
if pin_name in SKIP_PARAMS:
continue
try:
ok = L.set_pin_default(target_graph, guid, pin_name, str(value))
if not ok:
result["warnings"].append(f"set_pin_default failed: {local_id}.{pin_name}")
except Exception as e: # noqa: BLE001
result["warnings"].append(f"set_pin_default raised: {local_id}.{pin_name}: {e}")
# ---- connect edges ----
def _resolve_ref(ref: str):
# "@guid:<NodeGuid>" -> raw guid (existing node)
if isinstance(ref, str) and ref.startswith("@guid:"):
return ref[len("@guid:"):]
return guid_map.get(ref)
for edge in graph.get("edges", []):
try:
(fa, fp) = edge["from"]
(tb, tp) = edge["to"]
ga, gb = _resolve_ref(fa), _resolve_ref(tb)
if not ga or not gb:
raise RuntimeError("edge references unspawned/unknown node")
ok = L.connect_pins(target_graph, ga, fp, gb, tp)
if not ok:
raise RuntimeError(f"connect_pins returned false ({fa}.{fp} -> {tb}.{tp})")
except Exception as e: # noqa: BLE001
result["errors"].append(f"edge {edge}: {e}")
if result["errors"]:
result["ok"] = False
else:
try:
msgs = list(L.compile_with_messages(bp))
result["compile_messages"] = msgs
result["compile"] = "ok" if all(not m.startswith("ERROR|") for m in msgs) else "errors"
except Exception:
L.compile_and_save_blueprint(bp)
result["compile"] = "ok"
unreal.EditorAssetLibrary.save_loaded_asset(bp)
result["ok"] = (result["compile"] == "ok")
except Exception as e: # noqa: BLE001
result["errors"].append(str(e))
result["traceback"] = traceback.format_exc()
return result
def _load_class(path_or_name: str):
if "/" in path_or_name:
return unreal.load_class(None, path_or_name)
for prefix in ("/Script/Engine.", "/Script/CoreUObject.", "/Script/UMG.",
"/Script/GameplayTags.", "/Script/UnrealEd.", "/Script/Kismet."):
c = unreal.load_class(None, prefix + path_or_name)
if c is not None:
return c
return None
def _load_struct(path: str):
if "/" in path:
return unreal.load_object(None, path)
return unreal.load_object(None, "/Script/CoreUObject." + path)
def _load_enum(path: str):
if "/" in path:
return unreal.load_object(None, path)
return unreal.load_object(None, "/Script/CoreUObject." + path)
def _spawn_one(graph, spec: dict, bp, L) -> str:
kind = spec["kind"]
pos = unreal.Vector2D(*(spec.get("position") or [0, 0]))
target = spec.get("target") or ""
# ---- Function / event ----
if kind == "call_function":
cls, fn = _resolve_function_owner(target)
return L.spawn_call_function_node(bp, graph, cls, fn, pos)
if kind == "event":
return L.spawn_event_node(bp, graph, None, target, pos)
if kind == "custom_event":
return L.spawn_custom_event_node(bp, graph, target, pos)
if kind == "component_event":
if "." not in target:
raise RuntimeError(f"component_event target must be 'Component.Delegate', got {target!r}")
comp, delegate = target.split(".", 1)
return L.spawn_component_bound_event(bp, graph, comp, delegate, pos)
# ---- Variables ----
if kind == "variable_get":
return L.spawn_variable_get_node(bp, graph, target, pos)
if kind == "variable_set":
return L.spawn_variable_set_node(bp, graph, target, pos)
if kind == "self":
return L.spawn_self_node(bp, graph, pos)
# ---- Flow control ----
if kind == "branch":
return L.spawn_branch_node(bp, graph, pos)
if kind == "sequence":
out_count = int((spec.get("params") or {}).get("then_count", 2))
return L.spawn_sequence_node(bp, graph, pos, out_count)
if kind == "switch_int":
return L.spawn_switch_on_int_node(bp, graph, pos)
if kind == "switch_string":
return L.spawn_switch_on_string_node(bp, graph, pos)
if kind == "switch_enum":
e = _load_enum(target)
if e is None:
raise RuntimeError(f"switch_enum: enum not found at {target!r}")
return L.spawn_switch_on_enum_node(bp, graph, e, pos)
if kind == "macro":
# target: macro name from StandardMacros (e.g. "ForLoop", "ForEachLoop",
# "WhileLoop", "DoOnce", "Gate", "FlipFlop", "MultiGate", "IsValid")
return L.spawn_standard_macro_node(bp, graph, target, pos)
# ---- Cast ----
if kind == "cast":
cls = _load_class(target)
if cls is None:
raise RuntimeError(f"cast: class not found at {target!r}")
return L.spawn_cast_node(bp, graph, cls, pos)
# ---- Collections / structs ----
if kind == "make_array":
return L.spawn_make_array_node(bp, graph, pos)
if kind == "make_struct":
s = _load_struct(target)
if s is None:
raise RuntimeError(f"make_struct: struct not found at {target!r}")
return L.spawn_make_struct_node(bp, graph, s, pos)
if kind == "break_struct":
s = _load_struct(target)
if s is None:
raise RuntimeError(f"break_struct: struct not found at {target!r}")
return L.spawn_break_struct_node(bp, graph, s, pos)
if kind == "set_fields_in_struct":
s = _load_struct(target)
if s is None:
raise RuntimeError(f"set_fields_in_struct: struct not found at {target!r}")
return L.spawn_set_fields_in_struct_node(bp, graph, s, pos)
# ---- Misc ----
if kind == "spawn_actor":
return L.spawn_spawn_actor_from_class_node(bp, graph, pos)
if kind == "format_text":
return L.spawn_format_text_node(bp, graph, pos)
if kind == "get_subsystem":
cls = _load_class(target)
if cls is None:
raise RuntimeError(f"get_subsystem: class not found at {target!r}")
return L.spawn_get_subsystem_node(bp, graph, cls, pos)
if kind == "load_asset":
return L.spawn_load_asset_node(bp, graph, pos)
# ---- Delegates ----
if kind in ("bind_delegate", "unbind_delegate", "assign_delegate"):
if "." not in target:
raise RuntimeError(f"{kind} target must be 'Component.Delegate', got {target!r}")
comp, delegate = target.split(".", 1)
fn = {
"bind_delegate": L.spawn_bind_delegate_node,
"unbind_delegate": L.spawn_unbind_delegate_node,
"assign_delegate": L.spawn_assign_delegate_node,
}[kind]
return fn(bp, graph, comp, delegate, pos)
if kind == "call_delegate":
return L.spawn_call_delegate_node(bp, graph, target, pos)
# ---- Tier 2 nodes ----
if kind == "knot":
return L.spawn_knot_node(bp, graph, pos)
if kind == "comment":
p = spec.get("params") or {}
size = unreal.Vector2D(float(p.get("width", 400)), float(p.get("height", 200)))
text = str(p.get("text", target or "Comment"))
return L.spawn_comment_node(bp, graph, pos, size, text)
if kind == "enum_literal":
e = _load_enum(target)
if e is None:
raise RuntimeError(f"enum_literal: enum not found at {target!r}")
return L.spawn_enum_literal_node(bp, graph, e, pos)
if kind == "get_class_defaults":
cls = _load_class(target)
if cls is None:
raise RuntimeError(f"get_class_defaults: class not found at {target!r}")
return L.spawn_get_class_defaults_node(bp, graph, cls, pos)
if kind == "function_result":
return L.spawn_function_result_node(bp, graph, pos)
if kind == "get_array_item":
return L.spawn_get_array_item_node(bp, graph, pos)
if kind == "operator":
# target = "+", "-", "*", etc.
return L.spawn_promotable_operator_node(bp, graph, target, pos)
raise RuntimeError(f"kind {kind!r} not supported")
# ---------------------------------------------------------------------------
# Validation (dry-resolve all targets without spawning)
# ---------------------------------------------------------------------------
def validate(graph: dict) -> dict:
"""Resolve all targets in NodeSpec without mutating UE.
Returns {ok, missing: [{id, kind, target, reason}], resolved: [{id, ...}]}.
"""
out = {"ok": True, "missing": [], "resolved": []}
if unreal is None:
out["ok"] = False
out["missing"].append({"reason": "unreal module not importable"})
return out
for spec in graph.get("nodes", []):
kind = spec.get("kind", "")
target = spec.get("target") or ""
try:
if kind == "call_function":
cls, fn = _resolve_function_owner(target)
if not cls.find_function(fn):
raise RuntimeError(f"function {fn!r} not on {cls.get_name()}")
elif kind in ("cast", "get_subsystem", "get_class_defaults"):
if _load_class(target) is None:
raise RuntimeError(f"class not found: {target!r}")
elif kind in ("make_struct", "break_struct", "set_fields_in_struct"):
if _load_struct(target) is None:
raise RuntimeError(f"struct not found: {target!r}")
elif kind in ("switch_enum", "enum_literal"):
if _load_enum(target) is None:
raise RuntimeError(f"enum not found: {target!r}")
out["resolved"].append({"id": spec.get("id"), "kind": kind, "target": target})
except Exception as e: # noqa: BLE001
out["ok"] = False
out["missing"].append({"id": spec.get("id"), "kind": kind, "target": target, "reason": str(e)})
return out
# ---------------------------------------------------------------------------
# read_graph — dump existing graph via C++ DumpGraphToJson
# ---------------------------------------------------------------------------
def read_graph(bp_path: str, function_name: str = "EventGraph") -> dict:
try:
L = _lib()
if bp_path:
bp = _load_blueprint(bp_path)
else:
bp, bp_path = _active_blueprint()
g = _find_graph(bp, function_name)
raw = L.dump_graph_to_json(g)
return {"ok": True, "bp_path": bp_path, "function": function_name, "graph": json.loads(raw)}
except Exception as e: # noqa: BLE001
return {"ok": False, "error": str(e), "traceback": traceback.format_exc()}
# ---------------------------------------------------------------------------
# Entrypoint (called by the MCP server's bootstrap snippet)
# ---------------------------------------------------------------------------
def entrypoint(payload_json: str) -> str:
try:
graph = json.loads(payload_json)
except Exception as e: # noqa: BLE001
result_json = json.dumps({"ok": False, "errors": [f"bad payload json: {e}"]})
_tee(result_json, {})
return result_json
op = graph.get("__op", "apply")
if op == "read":
out = read_graph(graph.get("bp_path") or "", graph.get("function") or "EventGraph")
elif op == "validate":
out = validate(graph)
elif op == "batch":
out = batch_apply(graph.get("ops") or [])
else:
out = apply(graph)
result_json = json.dumps(out)
_tee(result_json, graph)
return result_json
def batch_apply(ops: list) -> dict:
"""Run a list of apply()s atomically-ish — same transaction, single undo."""
results = []
for i, op in enumerate(ops):
op_kind = op.get("op") or "apply"
if op_kind == "apply":
results.append({"index": i, "op": "apply", "result": apply(op)})
elif op_kind == "validate":
results.append({"index": i, "op": "validate", "result": validate(op)})
elif op_kind == "read":
results.append({"index": i, "op": "read", "result": read_graph(op.get("bp_path") or "", op.get("function") or "EventGraph")})
else:
results.append({"index": i, "op": op_kind, "result": {"ok": False, "error": f"unknown op kind {op_kind!r}"}})
overall_ok = all(r["result"].get("ok") for r in results)
return {"ok": overall_ok, "results": results}
def _tee(result_json: str, payload: dict) -> None:
try:
if unreal is None:
return
proj_dir = unreal.Paths.project_saved_dir()
out_dir = proj_dir + "MCP/"
try:
os.makedirs(out_dir, exist_ok=True)
except Exception:
pass
rid = (payload or {}).get("__rid") or "last"
# Always write to last.json for human inspection, and to {rid}.json for race-free reads
with open(out_dir + "last.json", "w", encoding="utf-8") as f:
f.write(result_json)
if rid != "last":
with open(out_dir + f"{rid}.json", "w", encoding="utf-8") as f:
f.write(result_json)
unreal.log("[MCP] " + result_json[:1500])
except Exception:
pass

243
ue_side/assets.py Normal file
View File

@ -0,0 +1,243 @@
"""Asset CRUD via UE AssetTools — pure Python, no C++ needed.
Exposed ops (dispatched by `entrypoint(payload_json)` from the MCP server):
create_blueprint {path, parent_class}
create_widget_bp {path}
create_enum {path, values:[]}
create_struct {path, fields:[{name,type}]}
create_data_asset {path, class}
create_data_table {path, row_struct}
duplicate {src, dst}
rename {src, dst}
move {src, dst_folder}
delete {path}
reparent_blueprint {bp, new_parent_class}
"""
from __future__ import annotations
import json
import os
import traceback
try:
import unreal # type: ignore
except ImportError:
unreal = None
def _at():
return unreal.AssetToolsHelpers.get_asset_tools()
def _load_class(path_or_name: str):
if "/" in path_or_name:
return unreal.load_class(None, path_or_name)
for prefix in ("/Script/Engine.", "/Script/CoreUObject.", "/Script/UMG.",
"/Script/UnrealEd.", "/Script/Kismet."):
c = unreal.load_class(None, prefix + path_or_name)
if c is not None:
return c
return None
def _split_pkg(path: str):
"""/Game/Foo/Bar/Asset -> (/Game/Foo/Bar, Asset)"""
if "/" not in path:
raise RuntimeError(f"bad asset path {path!r}")
folder, name = path.rsplit("/", 1)
return folder, name
def create_blueprint(args: dict) -> dict:
path = args["path"]
parent = _load_class(args.get("parent_class") or "Actor")
if parent is None:
return {"ok": False, "error": f"parent class not found: {args.get('parent_class')!r}"}
folder, name = _split_pkg(path)
factory = unreal.BlueprintFactory()
factory.set_editor_property("parent_class", parent)
asset = _at().create_asset(name, folder, unreal.Blueprint, factory)
if asset is None:
return {"ok": False, "error": "create_asset returned None"}
unreal.EditorAssetLibrary.save_loaded_asset(asset)
return {"ok": True, "path": asset.get_path_name().split(".")[0], "parent": parent.get_name()}
def create_widget_bp(args: dict) -> dict:
path = args["path"]
folder, name = _split_pkg(path)
factory = unreal.WidgetBlueprintFactory()
asset = _at().create_asset(name, folder, unreal.WidgetBlueprint, factory)
if asset is None:
return {"ok": False, "error": "create_asset returned None"}
unreal.EditorAssetLibrary.save_loaded_asset(asset)
return {"ok": True, "path": asset.get_path_name().split(".")[0]}
def create_enum(args: dict) -> dict:
path = args["path"]
values = args.get("values") or []
folder, name = _split_pkg(path)
factory = unreal.EnumFactory()
asset = _at().create_asset(name, folder, unreal.UserDefinedEnum, factory)
if asset is None:
return {"ok": False, "error": "create_asset returned None"}
for i, v in enumerate(values):
try:
unreal.EnumEditorUtils.add_enumerator_to_user_defined_enum(asset, str(v))
except Exception:
pass
unreal.EditorAssetLibrary.save_loaded_asset(asset)
return {"ok": True, "path": asset.get_path_name().split(".")[0], "values_added": len(values)}
def create_struct(args: dict) -> dict:
path = args["path"]
fields = args.get("fields") or [] # [{name, type}]
folder, name = _split_pkg(path)
factory = unreal.StructureFactory()
asset = _at().create_asset(name, folder, unreal.UserDefinedStruct, factory)
if asset is None:
return {"ok": False, "error": "create_asset returned None"}
# Field editing via StructureEditorUtils not exposed reliably in stock Python.
# User can rename default member via UI; we just create the struct.
unreal.EditorAssetLibrary.save_loaded_asset(asset)
return {"ok": True, "path": asset.get_path_name().split(".")[0], "note": "Field add not exposed in stock Python; edit in editor or pre-create C++ struct."}
def create_data_asset(args: dict) -> dict:
path = args["path"]
cls = _load_class(args.get("class") or "")
if cls is None:
return {"ok": False, "error": f"class not found: {args.get('class')!r}"}
folder, name = _split_pkg(path)
factory = unreal.DataAssetFactory()
factory.set_editor_property("data_asset_class", cls)
asset = _at().create_asset(name, folder, cls, factory)
if asset is None:
return {"ok": False, "error": "create_asset returned None"}
unreal.EditorAssetLibrary.save_loaded_asset(asset)
return {"ok": True, "path": asset.get_path_name().split(".")[0]}
def create_data_table(args: dict) -> dict:
path = args["path"]
row_struct_path = args.get("row_struct") or ""
struct = unreal.load_object(None, row_struct_path) if row_struct_path else None
if struct is None:
return {"ok": False, "error": f"row_struct not found: {row_struct_path!r}"}
folder, name = _split_pkg(path)
factory = unreal.DataTableFactory()
factory.set_editor_property("struct", struct)
asset = _at().create_asset(name, folder, unreal.DataTable, factory)
if asset is None:
return {"ok": False, "error": "create_asset returned None"}
unreal.EditorAssetLibrary.save_loaded_asset(asset)
return {"ok": True, "path": asset.get_path_name().split(".")[0]}
def duplicate(args: dict) -> dict:
src, dst = args["src"], args["dst"]
asset = unreal.EditorAssetLibrary.duplicate_asset(src, dst)
return {"ok": asset is not None, "src": src, "dst": dst}
def rename(args: dict) -> dict:
src, dst = args["src"], args["dst"]
ok = unreal.EditorAssetLibrary.rename_asset(src, dst)
return {"ok": bool(ok), "src": src, "dst": dst}
def move(args: dict) -> dict:
src, folder = args["src"], args["dst_folder"]
name = src.rsplit("/", 1)[-1]
dst = folder.rstrip("/") + "/" + name
ok = unreal.EditorAssetLibrary.rename_asset(src, dst)
return {"ok": bool(ok), "src": src, "dst": dst}
def delete(args: dict) -> dict:
path = args["path"]
ok = unreal.EditorAssetLibrary.delete_asset(path)
return {"ok": bool(ok), "path": path}
def reparent_blueprint(args: dict) -> dict:
bp_path = args["bp"]
new_parent = _load_class(args["new_parent_class"])
if new_parent is None:
return {"ok": False, "error": f"new_parent_class not found: {args['new_parent_class']!r}"}
bp = unreal.EditorAssetLibrary.load_asset(bp_path)
if not isinstance(bp, unreal.Blueprint):
return {"ok": False, "error": "not a blueprint"}
# WidgetBlueprint (and others) don't expose ParentClass as a settable editor
# property — use the proper reparent API, fall back to set_editor_property.
try:
unreal.BlueprintEditorLibrary.reparent_blueprint(bp, new_parent)
except Exception:
bp.set_editor_property("parent_class", new_parent)
unreal.BlueprintEditorLibrary.compile_blueprint(bp)
unreal.EditorAssetLibrary.save_loaded_asset(bp)
return {"ok": True, "bp": bp_path, "new_parent": new_parent.get_name()}
OPS = {
"create_blueprint": create_blueprint,
"create_widget_bp": create_widget_bp,
"create_enum": create_enum,
"create_struct": create_struct,
"create_data_asset": create_data_asset,
"create_data_table": create_data_table,
"duplicate": duplicate,
"rename": rename,
"move": move,
"delete": delete,
"reparent_blueprint": reparent_blueprint,
}
# Member-variable authoring lives in variables.py and is surfaced as the
# dedicated `variable_op` tool. Merge its NON-colliding op names here too so the
# capability is reachable via `asset_op` without a JS server restart. (We skip
# the short aliases add/rename/delete/list — they would shadow asset ops.)
try:
import importlib as _il
import variables as _vars
_il.reload(_vars)
for _k in ("add_variable", "set_var_default", "set_var_meta",
"rename_variable", "delete_variable", "list_variables"):
OPS[_k] = _vars.VAR_OPS[_k]
except Exception:
pass
def entrypoint(payload_json: str) -> str:
try:
p = json.loads(payload_json)
except Exception as e: # noqa: BLE001
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
op = p.get("op")
fn = OPS.get(op)
if not fn:
return _tee({"ok": False, "error": f"unknown op {op!r}", "available": list(OPS)}, p)
try:
return _tee(fn(p), p)
except Exception as e: # noqa: BLE001
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
def _tee(result: dict, p: dict) -> str:
txt = json.dumps(result)
try:
if unreal is not None:
sd = unreal.Paths.project_saved_dir() + "MCP/"
os.makedirs(sd, exist_ok=True)
rid = p.get("__rid") or "last"
with open(sd + "last.json", "w", encoding="utf-8") as f:
f.write(txt)
if rid != "last":
with open(sd + f"{rid}.json", "w", encoding="utf-8") as f:
f.write(txt)
unreal.log("[MCP-ASSETS] " + txt[:1500])
except Exception:
pass
return txt

145
ue_side/audio.py Normal file
View File

@ -0,0 +1,145 @@
"""Audio ops — MetaSounds, SoundCue, Attenuation, SoundClass/Mix.
Ops:
create_metasound_source {path}
create_metasound_patch {path}
create_sound_cue {path}
create_sound_attenuation {path}
create_sound_class {path}
create_sound_mix {path}
set_property {path, property, value}
get_property {path, property}
list_sound_assets {folder?}
play_sound_2d {path} — preview in editor
"""
from __future__ import annotations
import json
import os
import traceback
try:
import unreal # type: ignore
except ImportError:
unreal = None
def _at():
return unreal.AssetToolsHelpers.get_asset_tools()
def _load(p): return unreal.EditorAssetLibrary.load_asset(p)
def _split(p):
folder, name = p.rsplit("/", 1)
return folder, name
def _create(path, asset_cls_name, factory_name):
folder, name = _split(path)
cls = getattr(unreal, asset_cls_name, None)
factory_cls = getattr(unreal, factory_name, None)
if cls is None or factory_cls is None:
return {"ok": False, "error": f"{asset_cls_name}/{factory_name} not exposed (plugin disabled?)"}
asset = _at().create_asset(name, folder, cls, factory_cls())
if asset is None:
return {"ok": False, "error": "create_asset returned None"}
unreal.EditorAssetLibrary.save_asset(asset.get_path_name())
return {"ok": True, "path": asset.get_path_name().split(".")[0]}
def create_metasound_source(args): return _create(args["path"], "MetaSoundSource", "MetaSoundSourceFactory")
def create_metasound_patch(args): return _create(args["path"], "MetaSoundPatch", "MetaSoundPatchFactory")
def create_sound_cue(args): return _create(args["path"], "SoundCue", "SoundCueFactoryNew")
def create_sound_attenuation(args): return _create(args["path"], "SoundAttenuation", "SoundAttenuationFactory")
def create_sound_class(args): return _create(args["path"], "SoundClass", "SoundClassFactory")
def create_sound_mix(args): return _create(args["path"], "SoundMix", "SoundMixFactory")
def set_property(args):
a = _load(args["path"])
if a is None:
return {"ok": False, "error": "asset not found"}
try:
a.set_editor_property(args["property"], args["value"])
unreal.EditorAssetLibrary.save_asset(a.get_path_name())
return {"ok": True}
except Exception as e:
return {"ok": False, "error": str(e)}
def get_property(args):
a = _load(args["path"])
if a is None:
return {"ok": False, "error": "asset not found"}
try:
v = a.get_editor_property(args["property"])
return {"ok": True, "value": str(v)}
except Exception as e:
return {"ok": False, "error": str(e)}
def list_sound_assets(args):
folder = args.get("folder") or "/Game"
ar = unreal.AssetRegistryHelpers.get_asset_registry()
classes = ["SoundCue", "SoundWave", "MetaSoundSource", "MetaSoundPatch",
"SoundAttenuation", "SoundClass", "SoundMix", "SoundConcurrency"]
filt = unreal.ARFilter(package_paths=[folder], recursive_paths=True, class_names=classes)
items = [{"path": str(d.package_name), "class": str(d.asset_class)} for d in ar.get_assets(filt)]
return {"ok": True, "count": len(items), "items": items}
def play_sound_2d(args):
a = _load(args["path"])
if a is None:
return {"ok": False, "error": "not found"}
try:
world = unreal.EditorLevelLibrary.get_editor_world()
unreal.GameplayStatics.play_sound2d(world, a)
return {"ok": True}
except Exception as e:
return {"ok": False, "error": str(e)}
OPS = {
"create_metasound_source": create_metasound_source,
"create_metasound_patch": create_metasound_patch,
"create_sound_cue": create_sound_cue,
"create_sound_attenuation": create_sound_attenuation,
"create_sound_class": create_sound_class,
"create_sound_mix": create_sound_mix,
"set_property": set_property,
"get_property": get_property,
"list_sound_assets": list_sound_assets,
"play_sound_2d": play_sound_2d,
}
def entrypoint(payload_json: str) -> str:
try:
p = json.loads(payload_json)
except Exception as e:
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
op = p.get("op")
fn = OPS.get(op)
if not fn:
return _tee({"ok": False, "error": f"unknown op {op!r}", "available": list(OPS)}, p)
try:
return _tee(fn(p), p)
except Exception as e:
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
def _tee(result, p):
txt = json.dumps(result)
try:
if unreal is not None:
sd = unreal.Paths.project_saved_dir() + "MCP/"
os.makedirs(sd, exist_ok=True)
rid = p.get("__rid") or "last"
with open(sd + "last.json", "w", encoding="utf-8") as f: f.write(txt)
if rid != "last":
with open(sd + f"{rid}.json", "w", encoding="utf-8") as f: f.write(txt)
unreal.log("[MCP-AUDIO] " + txt[:1500])
except Exception:
pass
return txt

213
ue_side/capture.py Normal file
View File

@ -0,0 +1,213 @@
"""Headless capture ops — SEE editor content without opening any tab/window.
Backed by the C++ helper unreal.MCPCaptureLibrary (Live-Coding compile the
plugin if it's missing). Every op writes a PNG under Saved/<out_dir>/ and
returns its absolute path + file:// URL. The JS layer (capture_op) reads that
PNG back and attaches it INLINE so the agent actually sees the image.
Ops:
asset {asset_path, size?:[512,512], out_dir?:'MCP/capture'}
Render ANY asset's thumbnail headlessly (mesh, material, texture,
blueprint, niagara, anim, sound, data asset, ...). No editor open.
thumbnail alias for asset.
material {material_path, size?} — alias for asset, reads nicer in intent.
mesh {mesh_path, size?} — alias for asset.
scene {location?:[x,y,z], rotation?:[pitch,yaw,roll], fov?:90,
size?:[1280,720], out_dir?} — render the editor world from an
arbitrary camera via an offscreen SceneCapture2D. No viewport.
scene_from_actor {actor, distance?:300, pitch?:-20, yaw?:0, fov?:75, size?}
— frame an existing level actor and capture it.
list_assets {dir} — quick content-browser listing to find paths to capture.
"""
from __future__ import annotations
import json
import os
import traceback
try:
import unreal # type: ignore
except ImportError:
unreal = None
def _cap_lib():
L = getattr(unreal, "MCPCaptureLibrary", None)
if L is None:
raise RuntimeError(
"MCPCaptureLibrary not exposed — Live-Coding compile the C++ plugin "
"(live_coding_op compile), then retry."
)
return L
def _out_dir(args) -> tuple[str, str]:
"""Returns (abs_dir_with_os_sep, abs_dir_forward_slash)."""
proj_saved = unreal.Paths.project_saved_dir()
sub = (args.get("out_dir") or "MCP/capture").strip("/")
fwd = proj_saved + sub + "/"
os.makedirs(fwd, exist_ok=True)
return fwd.rstrip("/").replace("/", os.sep), fwd
def _safe_name(path: str) -> str:
return path.rsplit("/", 1)[-1].split(".")[0] or "capture"
def _ok_png(full_path: str, strategy: str, size) -> dict:
norm = full_path.replace("\\", "/")
return {
"ok": True,
"strategy": strategy,
"png_path": full_path,
"file_url": "file:///" + norm,
"size": size,
}
def _vec(v, d=(0.0, 0.0, 0.0)):
if v is None:
v = d
return unreal.Vector(float(v[0]), float(v[1]), float(v[2]))
def _rot(v, d=(0.0, 0.0, 0.0)):
if v is None:
v = d
# UE Rotator(pitch, yaw, roll); we accept [pitch, yaw, roll].
return unreal.Rotator(float(v[0]), float(v[1]), float(v[2]))
def asset(args: dict) -> dict:
path = args.get("asset_path") or args.get("material_path") or args.get("mesh_path") or args.get("path")
if not path:
return {"ok": False, "error": "asset_path required"}
if not unreal.EditorAssetLibrary.does_asset_exist(path):
return {"ok": False, "error": f"asset does not exist: {path}"}
size = args.get("size") or [512, 512]
w, h = int(size[0]), int(size[1])
os_dir, _ = _out_dir(args)
name = _safe_name(path)
_ret = _cap_lib().render_asset_thumbnail_to_png(path, os_dir, name, w, h)
if isinstance(_ret, (tuple, list)):
if len(_ret) >= 3:
ok, full, err = _ret[0], _ret[1], _ret[2]
elif len(_ret) == 2:
full, err = _ret[0], _ret[1]
ok = bool(full)
else:
ok, full, err = bool(_ret[0]), (_ret[0] if isinstance(_ret[0], str) else ""), ""
else:
full = _ret
ok = bool(full)
err = ""
if not ok:
return {"ok": False, "error": err or "thumbnail render failed", "asset_path": path}
res = _ok_png(full, "AssetThumbnail", [w, h])
res["asset_path"] = path
return res
def scene(args: dict) -> dict:
size = args.get("size") or [1280, 720]
w, h = int(size[0]), int(size[1])
os_dir, _ = _out_dir(args)
name = args.get("name") or "scene"
loc = _vec(args.get("location"))
rot = _rot(args.get("rotation"))
fov = float(args.get("fov", 90.0))
ok, full, err = _cap_lib().capture_editor_scene_to_png(loc, rot, fov, os_dir, name, w, h)
if not ok:
return {"ok": False, "error": err or "scene capture failed"}
res = _ok_png(full, "SceneCapture2D", [w, h])
res["camera"] = {"location": [loc.x, loc.y, loc.z],
"rotation": [rot.pitch, rot.yaw, rot.roll], "fov": fov}
return res
def scene_from_actor(args: dict) -> dict:
name = args.get("actor")
if not name:
return {"ok": False, "error": "actor required"}
ess = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
target = None
for a in ess.get_all_level_actors():
if a and (a.get_actor_label() == name or a.get_name() == name):
target = a
break
if target is None:
return {"ok": False, "error": f"actor not found: {name}"}
origin, extent = target.get_actor_bounds(False)
radius = max(extent.x, extent.y, extent.z, 50.0)
pitch = float(args.get("pitch", -20.0))
yaw = float(args.get("yaw", 0.0))
fov = float(args.get("fov", 75.0))
dist = float(args.get("distance", radius * 3.0))
# Place camera 'dist' away along yaw/pitch, looking back at the actor center.
look_dir = unreal.MathLibrary.get_forward_vector(unreal.Rotator(pitch, yaw, 0.0))
cam_loc = origin - (look_dir * dist)
look_rot = unreal.MathLibrary.find_look_at_rotation(cam_loc, origin)
size = args.get("size") or [1280, 720]
w, h = int(size[0]), int(size[1])
os_dir, _ = _out_dir(args)
fname = args.get("name") or f"actor_{_safe_name(name)}"
ok, full, err = _cap_lib().capture_editor_scene_to_png(cam_loc, look_rot, fov, os_dir, fname, w, h)
if not ok:
return {"ok": False, "error": err or "scene capture failed"}
res = _ok_png(full, "SceneCapture2D/actor", [w, h])
res["actor"] = name
res["camera"] = {"location": [cam_loc.x, cam_loc.y, cam_loc.z], "fov": fov}
return res
def list_assets(args: dict) -> dict:
folder = args.get("dir") or "/Game"
recursive = bool(args.get("recursive", False))
paths = unreal.EditorAssetLibrary.list_assets(folder, recursive=recursive, include_folder=False)
return {"ok": True, "dir": folder, "count": len(paths), "assets": list(paths)[:500]}
OPS = {
"asset": asset,
"thumbnail": asset,
"material": asset,
"mesh": asset,
"scene": scene,
"scene_from_actor": scene_from_actor,
"list_assets": list_assets,
}
def entrypoint(payload_json: str) -> str:
try:
p = json.loads(payload_json)
except Exception as e: # noqa: BLE001
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
fn = OPS.get(p.get("op"))
if not fn:
return _tee({"ok": False, "error": f"unknown op {p.get('op')!r}", "available": list(OPS)}, p)
try:
return _tee(fn(p), p)
except Exception as e: # noqa: BLE001
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
def _tee(result: dict, p: dict) -> str:
txt = json.dumps(result)
try:
if unreal is not None:
sd = unreal.Paths.project_saved_dir() + "MCP/"
os.makedirs(sd, exist_ok=True)
rid = p.get("__rid") or "last"
with open(sd + "last.json", "w", encoding="utf-8") as f:
f.write(txt)
if rid != "last":
with open(sd + f"{rid}.json", "w", encoding="utf-8") as f:
f.write(txt)
unreal.log("[MCP-CAPTURE] " + txt[:1500])
except Exception:
pass
return txt

118
ue_side/console.py Normal file
View File

@ -0,0 +1,118 @@
"""Console commands and CVars.
Ops:
run {command} — execute editor console command
get_cvar {name}
set_cvar {name, value}
list_cvars {prefix?} — search registered cvars by prefix (best-effort)
stat {name, enable?:true} — toggle stat command (`stat fps`, `stat unit`, ...)
"""
from __future__ import annotations
import json
import os
import traceback
try:
import unreal # type: ignore
except ImportError:
unreal = None
def _world():
return unreal.EditorLevelLibrary.get_editor_world()
def run(args):
cmd = args["command"]
try:
unreal.SystemLibrary.execute_console_command(_world(), cmd)
return {"ok": True, "command": cmd}
except Exception as e:
return {"ok": False, "error": str(e)}
def get_cvar(args):
name = args["name"]
# No direct read API in stable Python; we issue ?-style print and grab from log.
try:
unreal.SystemLibrary.execute_console_command(_world(), name)
# Heuristic: also use ConsoleVariablesEditorFunctionLibrary if available
cvl = getattr(unreal, "ConsoleVariablesEditorFunctionLibrary", None)
if cvl and hasattr(cvl, "get_console_variable_string_value"):
return {"ok": True, "name": name, "value": cvl.get_console_variable_string_value(name)}
return {"ok": True, "name": name, "note": "value echoed to Output Log (no direct read API)"}
except Exception as e:
return {"ok": False, "error": str(e)}
def set_cvar(args):
name = args["name"]
val = args["value"]
cmd = f"{name} {val}"
try:
unreal.SystemLibrary.execute_console_command(_world(), cmd)
return {"ok": True, "command": cmd}
except Exception as e:
return {"ok": False, "error": str(e)}
def list_cvars(args):
prefix = args.get("prefix") or ""
cmd = f"DumpConsoleCommands {prefix}".strip()
try:
unreal.SystemLibrary.execute_console_command(_world(), cmd)
return {"ok": True, "note": "results dumped to log; use read_python_log or grep the project log",
"command": cmd}
except Exception as e:
return {"ok": False, "error": str(e)}
def stat(args):
name = args["name"]
if not name.lower().startswith("stat "):
name = "stat " + name
try:
unreal.SystemLibrary.execute_console_command(_world(), name)
return {"ok": True, "command": name}
except Exception as e:
return {"ok": False, "error": str(e)}
OPS = {
"run": run,
"get_cvar": get_cvar,
"set_cvar": set_cvar,
"list_cvars": list_cvars,
"stat": stat,
}
def entrypoint(payload_json):
try:
p = json.loads(payload_json)
except Exception as e:
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
op = p.get("op"); fn = OPS.get(op)
if not fn:
return _tee({"ok": False, "error": f"unknown op {op!r}", "available": list(OPS)}, p)
try:
return _tee(fn(p), p)
except Exception as e:
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
def _tee(result, p):
txt = json.dumps(result)
try:
if unreal is not None:
sd = unreal.Paths.project_saved_dir() + "MCP/"
os.makedirs(sd, exist_ok=True)
rid = p.get("__rid") or "last"
with open(sd + "last.json", "w", encoding="utf-8") as f: f.write(txt)
if rid != "last":
with open(sd + f"{rid}.json", "w", encoding="utf-8") as f: f.write(txt)
unreal.log("[MCP-CONSOLE] " + txt[:1500])
except Exception:
pass
return txt

271
ue_side/cpp_scaffold.py Normal file
View File

@ -0,0 +1,271 @@
"""C++ class scaffold — generate .h/.cpp templates under a project module.
This does NOT compile the code — pair with live_coding.compile or a regular
hot-reload after the files appear. UE will pick up new files on next build.
Ops:
list_modules {} — read Source/*/Build.cs
create_class {name, parent_class, module?(default: primary game module), subfolder?, header_only?:false, public?:true, api_macro?:auto}
list_classes {module?, subfolder?} — list .h files under a module
read_class {name, module?} — return .h+.cpp text
"""
from __future__ import annotations
import json
import os
import re
import traceback
try:
import unreal # type: ignore
except ImportError:
unreal = None
def _project_dir():
if unreal is not None:
return unreal.Paths.convert_relative_path_to_full(unreal.Paths.project_dir()).rstrip("/\\")
return os.getcwd()
def _source_dir():
return os.path.join(_project_dir(), "Source")
def _project_name():
"""Project name = the .uproject base name (also the conventional primary module)."""
if unreal is not None:
try:
up = unreal.Paths.get_project_file_path() # e.g. .../MyGame.uproject
return os.path.splitext(os.path.basename(up))[0]
except Exception:
pass
return os.path.basename(_project_dir().rstrip("/\\"))
def _default_module():
"""Primary game module to scaffold into. Prefer the Runtime module declared in
the .uproject; fall back to the module whose folder matches the project name;
else the first Source module; else the project name."""
proj = _project_name()
# 1. Read the .uproject Modules list for a Runtime/Game module.
try:
up = unreal.Paths.get_project_file_path() if unreal is not None else None
if up and os.path.isfile(up):
with open(up, "r", encoding="utf-8") as f:
data = json.load(f)
mods = data.get("Modules") or []
runtime = [m for m in mods if str(m.get("Type", "")).lower() in ("runtime", "")]
if runtime:
return runtime[0].get("Name") or proj
if mods:
return mods[0].get("Name") or proj
except Exception:
pass
# 2. Folder matching the project name.
src = _source_dir()
if os.path.isdir(os.path.join(src, proj)):
return proj
# 3. First Source module with a Build.cs.
try:
for name in sorted(os.listdir(src)):
if os.path.isfile(os.path.join(src, name, f"{name}.Build.cs")):
return name
except Exception:
pass
return proj
def list_modules(args):
out = []
src = _source_dir()
if not os.path.isdir(src):
return {"ok": False, "error": f"no Source dir at {src}"}
for name in os.listdir(src):
p = os.path.join(src, name)
if os.path.isdir(p):
build_cs = os.path.join(p, f"{name}.Build.cs")
if os.path.isfile(build_cs):
out.append({"name": name, "build_cs": build_cs,
"has_public": os.path.isdir(os.path.join(p, "Public")),
"has_private": os.path.isdir(os.path.join(p, "Private"))})
return {"ok": True, "modules": out}
def _api_macro(module):
return f"{module.upper()}_API"
_HEADER_TPL = """// Generated by UEBlueprintMCP cpp_scaffold.
#pragma once
#include "CoreMinimal.h"
#include "{base_header}"
#include "{name}.generated.h"
UCLASS()
class {api} {name} : public {parent}
{{
\tGENERATED_BODY()
public:
\t{name}();
}};
"""
_CPP_TPL = """// Generated by UEBlueprintMCP cpp_scaffold.
#include "{include_path}{name}.h"
{name}::{name}()
{{
\tPrimaryActorTick.bCanEverTick = false;
}}
"""
# Map of common parents -> include they need
_PARENT_HEADERS = {
"AActor": "GameFramework/Actor.h",
"APawn": "GameFramework/Pawn.h",
"ACharacter": "GameFramework/Character.h",
"APlayerController": "GameFramework/PlayerController.h",
"AGameModeBase": "GameFramework/GameModeBase.h",
"AAIController": "AIController.h",
"UObject": "UObject/NoExportTypes.h",
"UActorComponent": "Components/ActorComponent.h",
"USceneComponent": "Components/SceneComponent.h",
"UPrimitiveComponent": "Components/PrimitiveComponent.h",
"UStaticMeshComponent": "Components/StaticMeshComponent.h",
"USkeletalMeshComponent":"Components/SkeletalMeshComponent.h",
"UAnimInstance": "Animation/AnimInstance.h",
"UUserWidget": "Blueprint/UserWidget.h",
"UDataAsset": "Engine/DataAsset.h",
"UInterface": "UObject/Interface.h",
}
def create_class(args):
name = args["name"]
parent = args["parent_class"] # e.g. AActor / UObject / UActorComponent
module = args.get("module") or _default_module()
subfolder = (args.get("subfolder") or "").replace("\\", "/").strip("/")
header_only = bool(args.get("header_only"))
public = bool(args.get("public", True))
if not re.match(r"^[AU][A-Z]\w+$", name):
return {"ok": False, "error": "class name must start with A or U followed by PascalCase (e.g. AMyActor, UMyComp)"}
src_mod = os.path.join(_source_dir(), module)
if not os.path.isdir(src_mod):
return {"ok": False, "error": f"module not found: {module}"}
pub_dir = os.path.join(src_mod, "Public", subfolder) if public else os.path.join(src_mod, subfolder)
priv_dir = os.path.join(src_mod, "Private", subfolder) if public else os.path.join(src_mod, subfolder)
os.makedirs(pub_dir, exist_ok=True)
if not header_only:
os.makedirs(priv_dir, exist_ok=True)
base_header = args.get("base_header") or _PARENT_HEADERS.get(parent, "CoreMinimal.h")
api = args.get("api_macro") or _api_macro(module)
h_path = os.path.join(pub_dir, f"{name}.h")
c_path = os.path.join(priv_dir, f"{name}.cpp")
if os.path.exists(h_path):
return {"ok": False, "error": f"file exists: {h_path}"}
h_txt = _HEADER_TPL.format(name=name, parent=parent, api=api, base_header=base_header)
with open(h_path, "w", encoding="utf-8", newline="\n") as f:
f.write(h_txt)
out = {"ok": True, "header": h_path}
if not header_only:
include_rel = (subfolder + "/") if subfolder else ""
c_txt = _CPP_TPL.format(name=name, include_path=include_rel)
with open(c_path, "w", encoding="utf-8", newline="\n") as f:
f.write(c_txt)
out["cpp"] = c_path
out["note"] = "Files written. Run live_coding.compile or rebuild from VS to register the class."
return out
def list_classes(args):
module = args.get("module") or _default_module()
subfolder = (args.get("subfolder") or "").replace("\\", "/").strip("/")
mod_root = os.path.join(_source_dir(), module)
if not os.path.isdir(mod_root):
return {"ok": False, "error": f"module not found: {mod_root}"}
scan_root = os.path.join(mod_root, subfolder) if subfolder else mod_root
out = []
for dirpath, _, fnames in os.walk(scan_root):
for fn in fnames:
if fn.endswith(".h"):
full = os.path.join(dirpath, fn)
rel = os.path.relpath(full, mod_root).replace("\\", "/")
# Skip the module's own header (e.g. <Module>.h)
if rel == f"{module}.h":
continue
out.append(rel)
return {"ok": True, "module": module, "headers": sorted(out), "count": len(out)}
def read_class(args):
module = args.get("module") or _default_module()
name = args["name"]
found = {"header": None, "cpp": None}
for dirpath, _, fnames in os.walk(os.path.join(_source_dir(), module)):
for fn in fnames:
if fn == f"{name}.h":
found["header"] = os.path.join(dirpath, fn)
elif fn == f"{name}.cpp":
found["cpp"] = os.path.join(dirpath, fn)
out = {"ok": True}
for k, p in found.items():
if p and os.path.isfile(p):
try:
with open(p, "r", encoding="utf-8") as f:
out[k] = {"path": p, "content": f.read()}
except Exception as e:
out[k] = {"path": p, "error": str(e)}
if not (found["header"] or found["cpp"]):
return {"ok": False, "error": f"class {name} not found under Source/{module}"}
return out
OPS = {
"list_modules": list_modules,
"create_class": create_class,
"list_classes": list_classes,
"read_class": read_class,
}
def entrypoint(payload_json):
try:
p = json.loads(payload_json)
except Exception as e:
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
op = p.get("op"); fn = OPS.get(op)
if not fn:
return _tee({"ok": False, "error": f"unknown op {op!r}", "available": list(OPS)}, p)
try:
return _tee(fn(p), p)
except Exception as e:
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
def _tee(result, p):
txt = json.dumps(result)
try:
if unreal is not None:
sd = unreal.Paths.project_saved_dir() + "MCP/"
os.makedirs(sd, exist_ok=True)
rid = p.get("__rid") or "last"
with open(sd + "last.json", "w", encoding="utf-8") as f: f.write(txt)
if rid != "last":
with open(sd + f"{rid}.json", "w", encoding="utf-8") as f: f.write(txt)
unreal.log("[MCP-CPP] " + txt[:1500])
except Exception:
pass
return txt

227
ue_side/cull.py Normal file
View File

@ -0,0 +1,227 @@
"""Cull-distance ops — batch set/inspect Desired Max Draw Distance on ISM/HISM/StaticMesh components.
Encapsulates the gotchas learned the hard way:
* CachedMaxDrawDistance is READ-ONLY -> we only write LDMaxDrawDistance (+ SetCullDistance on live instances).
* SubobjectDataHandle must be unwrapped via k2_find_subobject_data_from_handle (UE 5.7).
* target='asset_template' edits the Blueprint asset (default for ALL instances, survives reconstruct);
target='level_instance' edits placed actors only (fast, but lost on BP recompile / PLA repack).
* only_zero keeps it idempotent and never clobbers hand-tuned non-zero distances.
Ops:
audit {scope?, asset_paths?, component_types?, target?} — read-only report, no mutation
apply {distance, scope?, asset_paths?, component_types?, only_zero?, target?, save?:true}
verify {...same as audit} — alias of audit (post-apply check)
scope: 'level' (default) | 'assets' | 'selection'
target: 'asset_template' (default) | 'level_instance'
component_types: subset of ['ISM','HISM','StaticMesh','Primitive'] (default ['ISM'])
distance: cm; 0 disables culling
"""
from __future__ import annotations
import json
import os
import traceback
try:
import unreal # type: ignore
except ImportError:
unreal = None
def _eas(): return unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
def _sub(): return unreal.get_engine_subsystem(unreal.SubobjectDataSubsystem)
def _type_classes(args):
types = args.get("component_types") or ["ISM"]
mapping = {
"ISM": unreal.InstancedStaticMeshComponent,
"HISM": unreal.HierarchicalInstancedStaticMeshComponent,
"StaticMesh": unreal.StaticMeshComponent,
"Primitive": unreal.PrimitiveComponent,
}
out = []
for t in types:
cls = mapping.get(t)
if cls is None:
raise ValueError(f"unknown component_type {t!r}; allowed: {list(mapping)}")
out.append(cls)
return out
def _matches(obj, classes):
return any(isinstance(obj, c) for c in classes)
def _asset_path_from_actor(actor):
gen = actor.get_class().get_path_name()
asset = gen.split(".")[0]
return asset[:-2] if asset.endswith("_C") else asset
# ---- target='asset_template' : edit Blueprint component templates -----------
def _template_components(bp, classes):
"""Returns deduped list of (name, component_template) for matching types."""
handles = _sub().k2_gather_subobject_data_for_blueprint(bp)
seen, out = set(), []
for h in handles:
data = _sub().k2_find_subobject_data_from_handle(h)
obj = unreal.SubobjectDataBlueprintFunctionLibrary.get_object(data)
if obj is None or not _matches(obj, classes):
continue
nm = obj.get_name()
if nm in seen:
continue
seen.add(nm)
out.append((nm, obj))
return out
def _collect_assets(args):
"""Returns dict {asset_path: blueprint_asset} for the requested scope."""
ael = unreal.EditorAssetLibrary
paths = {}
scope = args.get("scope") or "level"
if scope == "assets":
for p in (args.get("asset_paths") or []):
paths[p.split(".")[0]] = None
else: # level / selection -> discover from placed actors
classes = _type_classes(args)
actors = (_eas().get_selected_level_actors() if scope == "selection"
else _eas().get_all_level_actors())
for a in actors:
if any(a.get_components_by_class(c) for c in classes):
paths[_asset_path_from_actor(a)] = None
result = {}
for p in paths:
result[p] = ael.load_asset(p)
return result
def _run_assets(args, mutate):
classes = _type_classes(args)
distance = float(args.get("distance", 0.0))
only_zero = args.get("only_zero", True)
save = args.get("save", True)
rows, total_set = [], 0
for path, bp in _collect_assets(args).items():
if bp is None:
rows.append({"asset": path, "error": "load failed"}); continue
comps = _template_components(bp, classes)
vals, set_n = [], 0
for _nm, c in comps:
cur = float(c.get_editor_property("ld_max_draw_distance"))
if mutate:
if not (only_zero and abs(cur) > 0.001):
c.set_editor_property("ld_max_draw_distance", distance)
set_n += 1; cur = distance
vals.append(round(cur, 1))
if mutate and set_n:
unreal.BlueprintEditorLibrary.compile_blueprint(bp)
if save:
unreal.EditorAssetLibrary.save_asset(path)
total_set += 1
rows.append({
"asset": path, "components": len(comps),
"zeros": sum(1 for v in vals if abs(v) < 0.001),
"distinct": sorted(set(vals)), "set": set_n,
})
return {"ok": True, "target": "asset_template",
"assets_modified": total_set, "rows": rows}
# ---- target='level_instance' : edit placed actors --------------------------
def _run_instances(args, mutate):
classes = _type_classes(args)
distance = float(args.get("distance", 0.0))
only_zero = args.get("only_zero", True)
scope = args.get("scope") or "level"
actors = (_eas().get_selected_level_actors() if scope == "selection"
else _eas().get_all_level_actors())
rows, changed_actors, changed_comps = [], 0, 0
for a in actors:
comps, seen = [], set()
for c in classes:
for comp in a.get_components_by_class(c):
if comp.get_name() not in seen:
seen.add(comp.get_name()); comps.append(comp)
if not comps:
continue
vals, set_n = [], 0
for comp in comps:
cur = float(comp.get_editor_property("ld_max_draw_distance"))
if mutate and not (only_zero and abs(cur) > 0.001):
comp.modify()
comp.set_editor_property("ld_max_draw_distance", distance)
comp.set_cull_distance(distance) # updates cached + re-registers
set_n += 1; cur = distance
vals.append(round(cur, 1))
if mutate and set_n:
a.modify(); changed_actors += 1; changed_comps += set_n
rows.append({"actor": a.get_actor_label(), "components": len(comps),
"zeros": sum(1 for v in vals if abs(v) < 0.001),
"distinct": sorted(set(vals)), "set": set_n})
return {"ok": True, "target": "level_instance",
"actors_modified": changed_actors, "components_set": changed_comps,
"rows": rows, "note": "instance edits are lost on BP recompile / PLA repack"}
# ---- op table --------------------------------------------------------------
def _dispatch(args, mutate):
target = args.get("target") or "asset_template"
if target == "asset_template":
return _run_assets(args, mutate)
if target == "level_instance":
return _run_instances(args, mutate)
return {"ok": False, "error": f"unknown target {target!r}; use 'asset_template' or 'level_instance'"}
def audit(args):
return _dispatch(args, mutate=False)
def verify(args):
return _dispatch(args, mutate=False)
def apply(args):
if "distance" not in args:
return {"ok": False, "error": "apply requires 'distance' (cm)"}
return _dispatch(args, mutate=True)
OPS = {"audit": audit, "verify": verify, "apply": apply}
def entrypoint(payload_json):
try:
p = json.loads(payload_json)
except Exception as e:
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
op = p.get("op"); fn = OPS.get(op)
if not fn:
return _tee({"ok": False, "error": f"unknown op {op!r}", "available": list(OPS)}, p)
try:
return _tee(fn(p), p)
except Exception as e:
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
def _tee(result, p):
txt = json.dumps(result)
try:
if unreal is not None:
sd = unreal.Paths.project_saved_dir() + "MCP/"
os.makedirs(sd, exist_ok=True)
rid = p.get("__rid") or "last"
with open(sd + "last.json", "w", encoding="utf-8") as f: f.write(txt)
if rid != "last":
with open(sd + f"{rid}.json", "w", encoding="utf-8") as f: f.write(txt)
unreal.log("[MCP-CULL] " + txt[:1500])
except Exception:
pass
return txt

153
ue_side/discover.py Normal file
View File

@ -0,0 +1,153 @@
"""Read-only discovery ops via MCPGraphLibrary v0.3.
Ops:
list_open_bps {} — open BP editors
list_graphs {bp_path} — all graphs of a BP
describe_bp {bp_path} — parent/interfaces/vars/components/funcs/dispatchers
get_selection {bp_path} — selected nodes in BP editor
resolve_call {bp_path, function, guid} — CallFunction node → source bp/class
find_var_refs {bp_path, name} — variable usage sites
find_fn_refs {bp_path, name} — function call sites in this BP
find_bp_by_parent {parent_class_path} — AssetRegistry search
get_refs_of {asset_path} — assets referenced by this asset
get_referencers {asset_path} — assets that reference this asset
read_function {bp_path, function} — wrapper over apply_graph.read_graph for function
"""
from __future__ import annotations
import json
import os
import traceback
try:
import unreal # type: ignore
except ImportError:
unreal = None
def _lib():
L = getattr(unreal, "MCPGraphLibrary", None)
if L is None:
raise RuntimeError("MCPGraphLibrary missing — rebuild C++ plugin")
return L
def _load_bp(path: str):
a = unreal.EditorAssetLibrary.load_asset(path)
if not isinstance(a, unreal.Blueprint):
raise RuntimeError(f"not a blueprint: {path!r}")
return a
def _find_graph(bp, name: str):
L = _lib()
if name in (None, "", "EventGraph"):
return L.find_event_graph(bp)
return L.find_function_graph(bp, name)
def list_open_bps(args: dict) -> dict:
return {"ok": True, "open": list(_lib().list_open_blueprints())}
def list_graphs(args: dict) -> dict:
bp = _load_bp(args["bp_path"])
raw = _lib().list_graphs_json(bp)
return {"ok": True, "bp_path": args["bp_path"], **json.loads(raw)}
def describe_bp(args: dict) -> dict:
bp = _load_bp(args["bp_path"])
raw = _lib().describe_blueprint_json(bp)
return {"ok": True, **json.loads(raw)}
def get_selection(args: dict) -> dict:
bp = _load_bp(args["bp_path"])
raw = _lib().get_selected_nodes_json(bp)
return {"ok": True, "bp_path": args["bp_path"], **json.loads(raw)}
def resolve_call(args: dict) -> dict:
bp = _load_bp(args["bp_path"])
g = _find_graph(bp, args.get("function") or "EventGraph")
if g is None:
return {"ok": False, "error": "graph not found"}
raw = _lib().resolve_function_source_json(g, args["guid"])
return {"ok": True, **json.loads(raw)}
def find_var_refs(args: dict) -> dict:
bp = _load_bp(args["bp_path"])
raw = _lib().find_variable_references_json(bp, args["name"])
return {"ok": True, **json.loads(raw)}
def find_fn_refs(args: dict) -> dict:
bp = _load_bp(args["bp_path"])
raw = _lib().find_function_references_json(bp, args["name"])
return {"ok": True, **json.loads(raw)}
def find_bp_by_parent(args: dict) -> dict:
found = list(_lib().find_blueprints_by_parent(args["parent_class_path"]))
return {"ok": True, "blueprints": found, "count": len(found)}
def get_refs_of(args: dict) -> dict:
return {"ok": True, "references": list(_lib().get_referenced_assets(args["asset_path"]))}
def get_referencers(args: dict) -> dict:
return {"ok": True, "referencers": list(_lib().get_referencers_of_asset(args["asset_path"]))}
def read_function(args: dict) -> dict:
# Convenience: read_graph for a named function
import importlib, apply_graph
importlib.reload(apply_graph)
return apply_graph.read_graph(args["bp_path"], args.get("function") or "EventGraph")
OPS = {
"list_open_bps": list_open_bps,
"list_graphs": list_graphs,
"describe_bp": describe_bp,
"get_selection": get_selection,
"resolve_call": resolve_call,
"find_var_refs": find_var_refs,
"find_fn_refs": find_fn_refs,
"find_bp_by_parent": find_bp_by_parent,
"get_refs_of": get_refs_of,
"get_referencers": get_referencers,
"read_function": read_function,
}
def entrypoint(payload_json: str) -> str:
try:
p = json.loads(payload_json)
except Exception as e: # noqa: BLE001
return _tee({"ok": False, "error": f"bad json: {e}"}, p={})
fn = OPS.get(p.get("op"))
if not fn:
return _tee({"ok": False, "error": f"unknown op {p.get('op')!r}", "available": list(OPS)}, p)
try:
return _tee(fn(p), p)
except Exception as e: # noqa: BLE001
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
def _tee(result: dict, p: dict) -> str:
txt = json.dumps(result)
try:
if unreal is not None:
sd = unreal.Paths.project_saved_dir() + "MCP/"
os.makedirs(sd, exist_ok=True)
rid = p.get("__rid") or "last"
with open(sd + f"{rid}.json", "w", encoding="utf-8") as f:
f.write(txt)
unreal.log("[MCP-DISCOVER] " + txt[:1500])
except Exception:
pass
return txt

186
ue_side/edit_ops.py Normal file
View File

@ -0,0 +1,186 @@
"""Single-node / single-edge edit ops + navigation.
Ops:
delete_node {bp_path, function?, guid}
break_link {bp_path, function?, from:[guid,pin], to:[guid,pin]}
break_all {bp_path, function?, guid}
move_nodes {bp_path, function?, moves:[{guid,x,y}]}
resize_comment {bp_path, function?, guid, width, height}
set_comment {bp_path, function?, guid, text, bubble?}
open_bp {bp_path}
open_graph {bp_path, function}
jump_to_node {bp_path, function?, guid}
add_case_pin {bp_path, function?, guid} — switch ops
format_text {bp_path, function?, guid, format}
reconstruct {bp_path, function?, guid}
"""
from __future__ import annotations
import json
import os
import traceback
try:
import unreal # type: ignore
except ImportError:
unreal = None
def _lib():
L = getattr(unreal, "MCPGraphLibrary", None)
if L is None:
raise RuntimeError("MCPGraphLibrary missing — rebuild C++ plugin")
return L
def _load_bp(path: str):
a = unreal.EditorAssetLibrary.load_asset(path)
if not isinstance(a, unreal.Blueprint):
raise RuntimeError(f"not a blueprint: {path!r}")
return a
def _graph(bp, fn: str | None):
L = _lib()
if fn in (None, "", "EventGraph"):
return L.find_event_graph(bp)
return L.find_function_graph(bp, fn)
def _compile_save(bp):
_lib().compile_and_save_blueprint(bp)
unreal.EditorAssetLibrary.save_loaded_asset(bp)
def delete_node(args: dict) -> dict:
bp = _load_bp(args["bp_path"])
g = _graph(bp, args.get("function"))
ok = bool(_lib().delete_node(g, args["guid"]))
if ok: _compile_save(bp)
return {"ok": ok, "guid": args["guid"]}
def break_link(args: dict) -> dict:
bp = _load_bp(args["bp_path"])
g = _graph(bp, args.get("function"))
(fg, fp) = args["from"]; (tg, tp) = args["to"]
ok = bool(_lib().break_link(g, fg, fp, tg, tp))
if ok: _compile_save(bp)
return {"ok": ok}
def break_all(args: dict) -> dict:
bp = _load_bp(args["bp_path"])
g = _graph(bp, args.get("function"))
ok = bool(_lib().break_all_node_links(g, args["guid"]))
if ok: _compile_save(bp)
return {"ok": ok}
def move_nodes(args: dict) -> dict:
bp = _load_bp(args["bp_path"])
g = _graph(bp, args.get("function"))
moves = args.get("moves") or []
ids = [m["guid"] for m in moves]
pos = [unreal.Vector2D(float(m["x"]), float(m["y"])) for m in moves]
n = int(_lib().move_nodes(g, ids, pos))
return {"ok": n > 0, "moved": n}
def resize_comment(args: dict) -> dict:
bp = _load_bp(args["bp_path"])
g = _graph(bp, args.get("function"))
ok = bool(_lib().resize_comment_node(g, args["guid"], unreal.Vector2D(float(args["width"]), float(args["height"]))))
return {"ok": ok}
def set_comment(args: dict) -> dict:
bp = _load_bp(args["bp_path"])
g = _graph(bp, args.get("function"))
ok = bool(_lib().set_node_comment(g, args["guid"], str(args.get("text", "")), bool(args.get("bubble", False))))
return {"ok": ok}
def open_bp(args: dict) -> dict:
bp = _load_bp(args["bp_path"])
return {"ok": bool(_lib().open_blueprint_editor(bp))}
def open_graph(args: dict) -> dict:
bp = _load_bp(args["bp_path"])
g = _graph(bp, args.get("function"))
return {"ok": bool(_lib().open_graph_in_editor(bp, g))}
def jump_to_node(args: dict) -> dict:
bp = _load_bp(args["bp_path"])
g = _graph(bp, args.get("function"))
return {"ok": bool(_lib().jump_to_node_in_editor(bp, g, args["guid"]))}
def add_case_pin(args: dict) -> dict:
bp = _load_bp(args["bp_path"])
g = _graph(bp, args.get("function"))
ok = bool(_lib().add_case_pin_to_switch(g, args["guid"]))
if ok: _compile_save(bp)
return {"ok": ok}
def format_text(args: dict) -> dict:
bp = _load_bp(args["bp_path"])
g = _graph(bp, args.get("function"))
ok = bool(_lib().format_text_set_format(g, args["guid"], args["format"]))
if ok: _compile_save(bp)
return {"ok": ok}
def reconstruct(args: dict) -> dict:
bp = _load_bp(args["bp_path"])
g = _graph(bp, args.get("function"))
ok = bool(_lib().reconstruct_node(g, args["guid"]))
return {"ok": ok}
OPS = {
"delete_node": delete_node,
"break_link": break_link,
"break_all": break_all,
"move_nodes": move_nodes,
"resize_comment": resize_comment,
"set_comment": set_comment,
"open_bp": open_bp,
"open_graph": open_graph,
"jump_to_node": jump_to_node,
"add_case_pin": add_case_pin,
"format_text": format_text,
"reconstruct": reconstruct,
}
def entrypoint(payload_json: str) -> str:
try:
p = json.loads(payload_json)
except Exception as e: # noqa: BLE001
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
fn = OPS.get(p.get("op"))
if not fn:
return _tee({"ok": False, "error": f"unknown op {p.get('op')!r}", "available": list(OPS)}, p)
try:
return _tee(fn(p), p)
except Exception as e: # noqa: BLE001
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
def _tee(result: dict, p: dict) -> str:
txt = json.dumps(result)
try:
if unreal is not None:
sd = unreal.Paths.project_saved_dir() + "MCP/"
os.makedirs(sd, exist_ok=True)
rid = p.get("__rid") or "last"
with open(sd + f"{rid}.json", "w", encoding="utf-8") as f:
f.write(txt)
unreal.log("[MCP-EDIT] " + txt[:1500])
except Exception:
pass
return txt

192
ue_side/graph_layout.py Normal file
View File

@ -0,0 +1,192 @@
"""Small deterministic graph layout helpers shared by MCP graph tools.
The goal is not to replace Unreal's graph layout. It is a guard rail for
generated graphs: keep columns readable and prevent heavy nodes from being
spawned on top of each other.
"""
from __future__ import annotations
DEFAULT_NODE_W = 260
DEFAULT_NODE_H = 150
DEFAULT_GAP_X = 180
DEFAULT_GAP_Y = 80
def estimate_size(spec: dict, domain: str = "blueprint") -> tuple[int, int]:
if domain == "pcg":
cls = str(spec.get("class") or spec.get("type") or "").lower()
# I/O nodes and reroutes are slim; spawners/samplers carry big detail panels.
if cls in ("input", "output"):
return 220, 120
if "spawner" in cls or "sampler" in cls or "subgraph" in cls:
return 340, 240
if "filter" in cls or "transform" in cls or "createpoints" in cls:
return 320, 200
return 300, 170
if domain == "material":
cls = str(spec.get("class") or spec.get("target") or "").lower()
if "texturesample" in cls:
return 310, 360
if "vectorparameter" in cls or "constant3vector" in cls or "constant4vector" in cls:
return 300, 300
if "scalarparameter" in cls:
return 260, 150
if "panner" in cls:
return 280, 190
if "linearinterpolate" in cls or "multiply" in cls or "add" in cls:
return 260, 170
if "texturecoordinate" in cls:
return 260, 130
return 280, 180
kind = str(spec.get("kind") or "").lower()
params = spec.get("params") or {}
if kind == "comment":
return int(params.get("width", 400)), int(params.get("height", 220))
if kind in ("event", "custom_event", "branch", "sequence", "operator", "knot"):
return 240, 120
if kind in ("call_function", "spawn_actor", "format_text"):
return 340, 190
if kind in ("make_struct", "break_struct", "set_fields_in_struct"):
return 360, 260
if kind in ("variable_get", "variable_set", "self"):
return 260, 130
return DEFAULT_NODE_W, DEFAULT_NODE_H
def normalize_specs(nodes: list[dict], edges: list[dict] | None = None, domain: str = "blueprint",
gap_x: int = DEFAULT_GAP_X, gap_y: int = DEFAULT_GAP_Y,
snap_x: int = 360) -> list[dict]:
"""Return copied nodes with collision-resistant positions.
The algorithm preserves the user's left-to-right intent from positions, but
snaps nearby x values into columns and stacks each column by estimated node
height. This keeps generated graphs stable and readable.
"""
copied = [dict(n) for n in (nodes or [])]
if len(copied) < 2:
return copied
with_meta = []
for index, node in enumerate(copied):
pos = node.get("position") or [0, 0]
x = float(pos[0]) if len(pos) > 0 else 0.0
y = float(pos[1]) if len(pos) > 1 else 0.0
w, h = estimate_size(node, domain)
with_meta.append({"index": index, "node": node, "x": x, "y": y, "w": w, "h": h})
sorted_by_x = sorted(with_meta, key=lambda item: (item["x"], item["y"], item["index"]))
columns: list[list[dict]] = []
for item in sorted_by_x:
if not columns:
columns.append([item])
continue
avg_x = sum(i["x"] for i in columns[-1]) / len(columns[-1])
if abs(item["x"] - avg_x) <= max(120, snap_x * 0.45):
columns[-1].append(item)
else:
columns.append([item])
min_x = min(item["x"] for item in with_meta)
for column_index, column in enumerate(columns):
column_x = min_x + column_index * (max(item["w"] for item in column) + gap_x)
column.sort(key=lambda item: (item["y"], item["index"]))
cursor_y = min(item["y"] for item in column)
for item in column:
desired_y = item["y"]
y = max(desired_y, cursor_y)
item["node"]["position"] = [round(column_x), round(y)]
cursor_y = y + item["h"] + gap_y
return copied
def tidy_existing_nodes(nodes: list[dict], edges: list[dict] | None = None, domain: str = "material") -> list[dict]:
"""Return [{guid/name,x,y}] for existing graph dump nodes."""
if edges:
return _tidy_existing_by_edges(nodes, edges, domain)
specs = []
for node in nodes or []:
cls = node.get("class") or ""
x = node.get("x", (node.get("position") or [0, 0])[0])
y = node.get("y", (node.get("position") or [0, 0])[1])
specs.append({
"id": node.get("guid") or node.get("name"),
"class": cls,
"position": [float(x), float(y)],
})
normalized = normalize_specs(specs, domain=domain)
moves = []
for spec in normalized:
x, y = spec.get("position") or [0, 0]
moves.append({"guid": spec.get("id"), "x": x, "y": y})
return moves
def _tidy_existing_by_edges(nodes: list[dict], edges: list[dict], domain: str) -> list[dict]:
by_id = {}
for node in nodes or []:
node_id = node.get("guid") or node.get("name")
if node_id:
by_id[node_id] = node
if not by_id:
return []
incoming = {node_id: set() for node_id in by_id}
outgoing = {node_id: set() for node_id in by_id}
for edge in edges or []:
try:
src = edge["from"][0]
dst = edge["to"][0]
except Exception:
continue
if src in by_id and dst in by_id:
outgoing[src].add(dst)
incoming[dst].add(src)
depths = {node_id: 0 for node_id in by_id}
changed = True
guard = 0
while changed and guard < max(4, len(by_id) * 3):
changed = False
guard += 1
for src, dsts in outgoing.items():
for dst in dsts:
candidate = depths[src] + 1
if candidate > depths[dst]:
depths[dst] = candidate
changed = True
columns: dict[int, list[dict]] = {}
for node_id, node in by_id.items():
columns.setdefault(depths.get(node_id, 0), []).append(node)
all_x = []
all_y = []
for node in by_id.values():
pos = node.get("position")
all_x.append(float(node.get("x", pos[0] if pos else 0)))
all_y.append(float(node.get("y", pos[1] if pos else 0)))
min_x = min(all_x) if all_x else 0
min_y = min(all_y) if all_y else 0
moves = []
sorted_depths = sorted(columns)
col_x = min_x
for depth in sorted_depths:
column = columns[depth]
column.sort(key=lambda node: float(node.get("y", (node.get("position") or [0, 0])[1])))
max_w = 0
cursor_y = min_y
for node in column:
spec = {"class": node.get("class"), "kind": node.get("kind")}
w, h = estimate_size(spec, domain)
max_w = max(max_w, w)
node_id = node.get("guid") or node.get("name")
moves.append({"guid": node_id, "x": round(col_x), "y": round(cursor_y)})
cursor_y += h + DEFAULT_GAP_Y
col_x += max_w + DEFAULT_GAP_X
return moves

187
ue_side/input.py Normal file
View File

@ -0,0 +1,187 @@
"""Enhanced Input ops — Input Actions, Input Mapping Contexts.
Ops:
create_input_action {path, value_type?:'Bool|Axis1D|Axis2D|Axis3D'}
create_input_mapping_context {path}
add_mapping {imc_path, action_path, key, modifiers?:[class_path], triggers?:[class_path]}
remove_mapping {imc_path, index}
list_mappings {imc_path}
"""
from __future__ import annotations
import json
import os
import traceback
try:
import unreal # type: ignore
except ImportError:
unreal = None
def _at(): return unreal.AssetToolsHelpers.get_asset_tools()
def _load(p): return unreal.EditorAssetLibrary.load_asset(p)
def _split(p):
folder, name = p.rsplit("/", 1)
return folder, name
VALUE_TYPES = {
"Bool": "Boolean",
"Axis1D": "Axis1D",
"Axis2D": "Axis2D",
"Axis3D": "Axis3D",
}
def create_input_action(args):
folder, name = _split(args["path"])
factory_cls = getattr(unreal, "InputActionFactory", None)
asset_cls = getattr(unreal, "InputAction", None)
if factory_cls is None or asset_cls is None:
return {"ok": False, "error": "Enhanced Input plugin not enabled"}
asset = _at().create_asset(name, folder, asset_cls, factory_cls())
if asset is None:
return {"ok": False, "error": "create returned None"}
vt = args.get("value_type") or "Bool"
enum_name = VALUE_TYPES.get(vt)
if enum_name:
try:
enum_obj = getattr(unreal.InputActionValueType, enum_name)
asset.set_editor_property("value_type", enum_obj)
except Exception:
pass
unreal.EditorAssetLibrary.save_asset(asset.get_path_name())
return {"ok": True, "path": asset.get_path_name().split(".")[0]}
def create_input_mapping_context(args):
folder, name = _split(args["path"])
factory_cls = getattr(unreal, "InputMappingContextFactory", None)
asset_cls = getattr(unreal, "InputMappingContext", None)
if factory_cls is None or asset_cls is None:
return {"ok": False, "error": "Enhanced Input plugin not enabled"}
asset = _at().create_asset(name, folder, asset_cls, factory_cls())
if asset is None:
return {"ok": False, "error": "create returned None"}
unreal.EditorAssetLibrary.save_asset(asset.get_path_name())
return {"ok": True, "path": asset.get_path_name().split(".")[0]}
def add_mapping(args):
imc = _load(args["imc_path"])
ia = _load(args["action_path"])
if imc is None or ia is None:
return {"ok": False, "error": "imc or action not found"}
key = unreal.InputCoreLibrary.input_key_from_string(args["key"]) \
if hasattr(unreal, "InputCoreLibrary") and hasattr(unreal.InputCoreLibrary, "input_key_from_string") \
else unreal.Key(args["key"])
mapping_cls = getattr(unreal, "EnhancedActionKeyMapping", None)
if mapping_cls is None:
try:
imc.map_key(ia, key)
unreal.EditorAssetLibrary.save_asset(imc.get_path_name())
return {"ok": True, "note": "added via map_key()"}
except Exception as e:
return {"ok": False, "error": f"map_key failed: {e}"}
m = mapping_cls()
m.set_editor_property("action", ia)
m.set_editor_property("key", key)
# Modifiers/triggers (class CDOs):
for cp in (args.get("modifiers") or []):
mc = unreal.load_class(None, cp)
if mc is not None:
try:
inst = unreal.new_object(mc)
mods = list(m.get_editor_property("modifiers") or [])
mods.append(inst)
m.set_editor_property("modifiers", mods)
except Exception:
pass
for cp in (args.get("triggers") or []):
tc = unreal.load_class(None, cp)
if tc is not None:
try:
inst = unreal.new_object(tc)
trigs = list(m.get_editor_property("triggers") or [])
trigs.append(inst)
m.set_editor_property("triggers", trigs)
except Exception:
pass
mappings = list(imc.get_editor_property("mappings") or [])
mappings.append(m)
imc.set_editor_property("mappings", mappings)
unreal.EditorAssetLibrary.save_asset(imc.get_path_name())
return {"ok": True, "index": len(mappings) - 1}
def remove_mapping(args):
imc = _load(args["imc_path"])
if imc is None:
return {"ok": False, "error": "imc not found"}
mappings = list(imc.get_editor_property("mappings") or [])
idx = int(args["index"])
if idx < 0 or idx >= len(mappings):
return {"ok": False, "error": "index out of range"}
mappings.pop(idx)
imc.set_editor_property("mappings", mappings)
unreal.EditorAssetLibrary.save_asset(imc.get_path_name())
return {"ok": True}
def list_mappings(args):
imc = _load(args["imc_path"])
if imc is None:
return {"ok": False, "error": "imc not found"}
out = []
for i, m in enumerate(imc.get_editor_property("mappings") or []):
try:
a = m.get_editor_property("action")
k = m.get_editor_property("key")
out.append({
"index": i,
"action": a.get_path_name().split(".")[0] if a else None,
"key": str(k),
})
except Exception:
pass
return {"ok": True, "mappings": out}
OPS = {
"create_input_action": create_input_action,
"create_input_mapping_context": create_input_mapping_context,
"add_mapping": add_mapping,
"remove_mapping": remove_mapping,
"list_mappings": list_mappings,
}
def entrypoint(payload_json):
try:
p = json.loads(payload_json)
except Exception as e:
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
op = p.get("op"); fn = OPS.get(op)
if not fn:
return _tee({"ok": False, "error": f"unknown op {op!r}", "available": list(OPS)}, p)
try:
return _tee(fn(p), p)
except Exception as e:
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
def _tee(result, p):
txt = json.dumps(result)
try:
if unreal is not None:
sd = unreal.Paths.project_saved_dir() + "MCP/"
os.makedirs(sd, exist_ok=True)
rid = p.get("__rid") or "last"
with open(sd + "last.json", "w", encoding="utf-8") as f: f.write(txt)
if rid != "last":
with open(sd + f"{rid}.json", "w", encoding="utf-8") as f: f.write(txt)
unreal.log("[MCP-INPUT] " + txt[:1500])
except Exception:
pass
return txt

202
ue_side/level.py Normal file
View File

@ -0,0 +1,202 @@
"""Level / scene ops via UE EditorActorSubsystem — pure Python, no C++ needed.
Ops (dispatched by `entrypoint(payload_json)`):
spawn_actor {class, location:[x,y,z]?, rotation:[p,y,r]?, scale:[x,y,z]?, name?}
destroy {name} — by actor label or path
get_selected {} — list selected actors
select {names:[]}
get_by_class {class}
find_by_name {name}
set_transform {name, location?, rotation?, scale?}
attach {child, parent, socket?}
save_level {}
load_level {path}
current_level {}
"""
from __future__ import annotations
import json
import os
import traceback
try:
import unreal # type: ignore
except ImportError:
unreal = None
def _ess():
return unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
def _load_class(p: str):
if "/" in p:
return unreal.load_class(None, p)
for prefix in ("/Script/Engine.", "/Script/UnrealEd."):
c = unreal.load_class(None, prefix + p)
if c is not None:
return c
return None
def _vec(v, default=(0, 0, 0)):
if v is None:
v = default
return unreal.Vector(float(v[0]), float(v[1]), float(v[2]))
def _rot(v, default=(0, 0, 0)):
if v is None:
v = default
# rotator: pitch, yaw, roll
return unreal.Rotator(float(v[0]), float(v[1]), float(v[2]))
def _find_actor(name: str):
for a in _ess().get_all_level_actors():
if not a:
continue
if a.get_actor_label() == name or a.get_name() == name or a.get_path_name() == name:
return a
return None
def spawn_actor(args: dict) -> dict:
cls = _load_class(args["class"])
if cls is None:
return {"ok": False, "error": f"class not found: {args['class']!r}"}
loc = _vec(args.get("location"))
rot = _rot(args.get("rotation"))
scale = _vec(args.get("scale"), (1, 1, 1))
actor = _ess().spawn_actor_from_class(cls, loc, rot)
if actor is None:
return {"ok": False, "error": "spawn returned None"}
actor.set_actor_scale3d(scale)
if args.get("name"):
actor.set_actor_label(args["name"])
return {"ok": True, "name": actor.get_actor_label(), "path": actor.get_path_name()}
def destroy(args: dict) -> dict:
a = _find_actor(args["name"])
if not a:
return {"ok": False, "error": "actor not found"}
_ess().destroy_actor(a)
return {"ok": True, "name": args["name"]}
def get_selected(args: dict) -> dict:
return {"ok": True, "actors": [a.get_actor_label() for a in _ess().get_selected_level_actors()]}
def select(args: dict) -> dict:
names = args.get("names") or []
found = [a for a in (_find_actor(n) for n in names) if a]
_ess().set_selected_level_actors(found)
return {"ok": True, "selected": [a.get_actor_label() for a in found]}
def get_by_class(args: dict) -> dict:
cls = _load_class(args["class"])
if cls is None:
return {"ok": False, "error": f"class not found: {args['class']!r}"}
found = [a for a in _ess().get_all_level_actors() if isinstance(a, cls)]
return {"ok": True, "actors": [a.get_actor_label() for a in found]}
def find_by_name(args: dict) -> dict:
a = _find_actor(args["name"])
if not a:
return {"ok": False, "error": "not found"}
return {"ok": True, "name": a.get_actor_label(), "path": a.get_path_name(), "class": a.get_class().get_name()}
def set_transform(args: dict) -> dict:
a = _find_actor(args["name"])
if not a:
return {"ok": False, "error": "actor not found"}
if args.get("location") is not None:
a.set_actor_location(_vec(args["location"]), False, False)
if args.get("rotation") is not None:
a.set_actor_rotation(_rot(args["rotation"]), False)
if args.get("scale") is not None:
a.set_actor_scale3d(_vec(args["scale"], (1, 1, 1)))
return {"ok": True}
def attach(args: dict) -> dict:
child = _find_actor(args["child"])
parent = _find_actor(args["parent"])
if not child or not parent:
return {"ok": False, "error": "child or parent not found"}
socket = args.get("socket") or ""
child.attach_to_actor(parent, socket, unreal.AttachmentRule.KEEP_WORLD,
unreal.AttachmentRule.KEEP_WORLD, unreal.AttachmentRule.KEEP_WORLD, False)
return {"ok": True}
def save_level(args: dict) -> dict:
les = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)
ok = les.save_current_level()
return {"ok": bool(ok)}
def load_level(args: dict) -> dict:
les = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)
ok = les.load_level(args["path"])
return {"ok": bool(ok), "path": args["path"]}
def current_level(args: dict) -> dict:
les = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)
name = les.get_current_level_name() if hasattr(les, "get_current_level_name") else ""
world = unreal.EditorLevelLibrary.get_editor_world() if hasattr(unreal, "EditorLevelLibrary") else None
return {"ok": True, "name": name, "world": world.get_path_name() if world else None}
OPS = {
"spawn_actor": spawn_actor,
"destroy": destroy,
"get_selected": get_selected,
"select": select,
"get_by_class": get_by_class,
"find_by_name": find_by_name,
"set_transform": set_transform,
"attach": attach,
"save_level": save_level,
"load_level": load_level,
"current_level": current_level,
}
def entrypoint(payload_json: str) -> str:
try:
p = json.loads(payload_json)
except Exception as e: # noqa: BLE001
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
op = p.get("op")
fn = OPS.get(op)
if not fn:
return _tee({"ok": False, "error": f"unknown op {op!r}", "available": list(OPS)}, p)
try:
return _tee(fn(p), p)
except Exception as e: # noqa: BLE001
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
def _tee(result: dict, p: dict) -> str:
txt = json.dumps(result)
try:
if unreal is not None:
sd = unreal.Paths.project_saved_dir() + "MCP/"
os.makedirs(sd, exist_ok=True)
rid = p.get("__rid") or "last"
with open(sd + "last.json", "w", encoding="utf-8") as f:
f.write(txt)
if rid != "last":
with open(sd + f"{rid}.json", "w", encoding="utf-8") as f:
f.write(txt)
unreal.log("[MCP-LEVEL] " + txt[:1500])
except Exception:
pass
return txt

307
ue_side/live_coding.py Normal file
View File

@ -0,0 +1,307 @@
"""Live Coding control — status, compile trigger, error parsing.
Why this exists: the AI repeatedly hits "Live Coding is open but won't build /
won't recompile". This module wraps ILiveCodingModule via Python where exposed,
falls back to console commands otherwise, and scrapes the latest LiveCoding log
for compile errors so the AI can iterate without screenshots.
Ops:
status {} — is_enabled, has_started_compile, pending_changes (best-effort)
compile {} — kick off a Live Coding patch
enable {}
disable {}
get_errors {tail?:200} — read last LiveCoding.log lines, filter for errors
open_lc_console{} — opens the LC window
"""
from __future__ import annotations
import json
import os
import re
import traceback
try:
import unreal # type: ignore
except ImportError:
unreal = None
def _project_log_path():
"""The main editor log: Saved/Logs/<ProjectName>.log. Project name is derived
from the .uproject so this is correct in any project, not just one."""
if unreal is None:
return None
try:
up = unreal.Paths.get_project_file_path()
name = os.path.splitext(os.path.basename(up))[0]
return unreal.Paths.project_saved_dir() + f"Logs/{name}.log"
except Exception:
return None
def _world():
return unreal.EditorLevelLibrary.get_editor_world()
def _exec(cmd):
unreal.SystemLibrary.execute_console_command(_world(), cmd)
def _lc_log_path():
"""Search a wide range of locations for LiveCoding.log + LiveCoding-backup-*.log.
Returns newest file by mtime."""
candidates = []
def _add_dir(d):
if not d or not os.path.isdir(d):
return
try:
for fn in os.listdir(d):
if fn.lower().startswith("livecoding") and fn.lower().endswith(".log"):
candidates.append(os.path.join(d, fn))
except Exception:
pass
# Project Saved/Logs
try:
_add_dir(unreal.Paths.project_saved_dir() + "Logs")
except Exception:
pass
# Engine Saved/Logs (project-relative)
try:
_add_dir(unreal.Paths.engine_saved_dir() + "Logs")
except Exception:
pass
# Engine Programs / LiveCodingConsole — this is where LC actually writes its server log.
# unreal.Paths.engine_dir() gives the engine root (relative or absolute).
try:
engine_root = unreal.Paths.convert_relative_path_to_full(unreal.Paths.engine_dir())
_add_dir(os.path.join(engine_root, "Programs", "LiveCodingConsole", "Saved", "Logs"))
except Exception:
pass
# %LOCALAPPDATA%\UnrealEngine\<Engine>\Saved\Logs
appdata = os.environ.get("LOCALAPPDATA", "")
if appdata:
ue_root = os.path.join(appdata, "UnrealEngine")
if os.path.isdir(ue_root):
for sub in os.listdir(ue_root):
_add_dir(os.path.join(ue_root, sub, "Saved", "Logs"))
# %APPDATA%\Unreal Engine\AutomationTool\Logs (sometimes UBT writes here)
appdata_roam = os.environ.get("APPDATA", "")
if appdata_roam:
_add_dir(os.path.join(appdata_roam, "Unreal Engine", "AutomationTool", "Logs"))
# Programs/UnrealBuildTool/Log.txt — different file but useful for compile errors
# We only care about LiveCoding here; UBT log is separate.
if not candidates:
return None
candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True)
return candidates[0]
def status(args):
out = {"ok": True}
try:
lcs = getattr(unreal, "LiveCodingSubsystem", None)
sub = unreal.get_editor_subsystem(lcs) if lcs else None
if sub is not None:
for attr in ("is_enabled", "has_started_compile", "is_compiling"):
if hasattr(sub, attr):
out[attr] = bool(getattr(sub, attr)())
else:
out["note"] = "LiveCodingSubsystem not exposed in Python; using log scrape + console"
except Exception as e:
out["query_error"] = str(e)
log = _lc_log_path()
out["lc_log_path"] = log
out["lc_log_exists"] = bool(log)
# Always also expose the main project log as the fallback error source.
fb = _project_log_path()
if fb:
out["fallback_log"] = fb
return out
def compile(args):
try:
lcs = getattr(unreal, "LiveCodingSubsystem", None)
sub = unreal.get_editor_subsystem(lcs) if lcs else None
if sub is not None and hasattr(sub, "compile"):
sub.compile()
return {"ok": True, "via": "subsystem"}
except Exception as e:
return {"ok": False, "error": str(e)}
try:
_exec("LiveCoding.Compile")
return {"ok": True, "via": "console"}
except Exception as e:
return {"ok": False, "error": str(e)}
def enable(args):
try: _exec("LiveCoding.Enable")
except Exception as e: return {"ok": False, "error": str(e)}
return {"ok": True}
def disable(args):
try: _exec("LiveCoding.Disable")
except Exception as e: return {"ok": False, "error": str(e)}
return {"ok": True}
def open_lc_console(args):
try: _exec("LiveCoding.Console")
except Exception as e: return {"ok": False, "error": str(e)}
return {"ok": True}
_ERR_RX = re.compile(r"\b(error\s+[A-Z]\d{3,5}|fatal error|undefined reference|cannot open|LINK\s*:\s*fatal|: error:|: warning:)\b", re.I)
_LC_CATEGORY_RX = re.compile(r"Log(LiveCoding|LiveCodingServer|Compile|HotReload|UnrealBuildTool)\s*:", re.I)
_LC_COMPILE_START_RX = re.compile(r"(Manual recompile triggered|Creating patch|Compiling|Starting compile)", re.I)
_LC_COMPILE_END_RX = re.compile(r"Finished\s*\(([\d.]+)s\)", re.I)
_LC_NO_CHANGES_RX = re.compile(r"No changes detected|nothing to compile", re.I)
def get_errors(args):
tail = int(args.get("tail") or 200)
log = _lc_log_path()
sources_used = []
if log is not None:
sources_used.append(log)
try:
with open(log, "r", encoding="utf-8", errors="replace") as f:
lines = f.readlines()
errs = [l.rstrip() for l in lines if _ERR_RX.search(l)]
if errs:
return {"ok": True, "source": "LiveCoding.log", "log_path": log,
"total_lines": len(lines), "errors_count": len(errs),
"errors": errs[-tail:]}
except Exception as e:
sources_used.append(f"(read error: {e})")
# Fallback: project log, filtered by LiveCoding/Compile/HotReload/UBT categories
fallback = _project_log_path()
if fallback and os.path.isfile(fallback):
sources_used.append(fallback)
try:
with open(fallback, "r", encoding="utf-8", errors="replace") as f:
lines = f.readlines()
relevant = [l.rstrip() for l in lines
if _LC_CATEGORY_RX.search(l) or _ERR_RX.search(l)]
errs = [l for l in relevant if _ERR_RX.search(l) or "Error" in l or "Fatal" in l]
return {"ok": True, "source": "project_log_fallback",
"log_path": fallback, "total_lines": len(lines),
"lc_lines": len(relevant), "errors_count": len(errs),
"lc_recent": relevant[-tail:],
"errors": errs[-tail:],
"sources_searched": sources_used}
except Exception as e:
return {"ok": False, "error": f"fallback read failed: {e}",
"sources_searched": sources_used}
return {"ok": False, "error": "no LiveCoding.log and no fallback project log",
"sources_searched": sources_used}
def last_compile(args):
"""Find the most recent compile cycle in LiveCodingConsole.log.
Returns: status ('success'|'failed'|'in_progress'|'no_recent'),
duration_s, timestamp, lines for the cycle, and any errors.
"""
log = _lc_log_path()
if log is None:
return {"ok": False, "error": "no LC log found"}
try:
with open(log, "r", encoding="utf-8", errors="replace") as f:
lines = f.readlines()
except Exception as e:
return {"ok": False, "error": str(e)}
# Walk backwards: find the last "Creating patch" / "Manual recompile" marker
start_idx = -1
for i in range(len(lines) - 1, -1, -1):
if _LC_COMPILE_START_RX.search(lines[i]):
start_idx = i
break
if start_idx == -1:
return {"ok": True, "log_path": log, "status": "no_recent",
"note": "no compile cycle found in log"}
cycle = [l.rstrip() for l in lines[start_idx:]]
timestamp_match = re.search(r"\[([\d.\-:]+)\]", cycle[0])
timestamp = timestamp_match.group(1) if timestamp_match else None
end_match = None
for l in cycle:
m = _LC_COMPILE_END_RX.search(l)
if m:
end_match = m
break
errors = [l for l in cycle if _ERR_RX.search(l)]
if errors:
status_str = "failed"
elif end_match:
status_str = "success"
else:
status_str = "in_progress"
return {
"ok": True,
"log_path": log,
"status": status_str,
"timestamp": timestamp,
"duration_s": float(end_match.group(1)) if end_match else None,
"errors_count": len(errors),
"errors": errors,
"cycle_lines": cycle,
}
OPS = {
"status": status,
"compile": compile,
"enable": enable,
"disable": disable,
"open_lc_console": open_lc_console,
"get_errors": get_errors,
"last_compile": last_compile,
}
def entrypoint(payload_json):
try:
p = json.loads(payload_json)
except Exception as e:
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
op = p.get("op"); fn = OPS.get(op)
if not fn:
return _tee({"ok": False, "error": f"unknown op {op!r}", "available": list(OPS)}, p)
try:
return _tee(fn(p), p)
except Exception as e:
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
def _tee(result, p):
txt = json.dumps(result)
try:
if unreal is not None:
sd = unreal.Paths.project_saved_dir() + "MCP/"
os.makedirs(sd, exist_ok=True)
rid = p.get("__rid") or "last"
with open(sd + "last.json", "w", encoding="utf-8") as f: f.write(txt)
if rid != "last":
with open(sd + f"{rid}.json", "w", encoding="utf-8") as f: f.write(txt)
unreal.log("[MCP-LC] " + txt[:1500])
except Exception:
pass
return txt

776
ue_side/materials.py Normal file
View File

@ -0,0 +1,776 @@
"""Material graph and material instance operations for UEBlueprintMCP.
Ops:
create_material{path}
set_material_settings{asset_path,properties}
inspect_material_settings{asset_path,properties:[...]}
create_material_instance{path,parent?}
read_graph{asset_path}
apply_graph{asset_path,clear_existing?,layout?,save?,nodes:[...],edges:[...],outputs:{...}}
add_node{asset_path,id?,class,position?,properties?}
delete_node{asset_path,guid|name}
move_nodes{asset_path,moves:[{guid|name,x,y}]}
set_node_property{asset_path,guid|name,property,value}
connect{asset_path,from:[guid|name,output],to:[guid|name,input]}
connect_output{asset_path,from:[guid|name,output],property}
layout{asset_path}
recompile_save{asset_path}
set_instance_parent{asset_path,parent}
set_instance_scalar{asset_path,name,value}
set_instance_vector{asset_path,name,value}
set_instance_texture{asset_path,name,value}
list_parameters{asset_path}
open_asset{asset_path}
"""
from __future__ import annotations
import json
import os
import traceback
import graph_layout
try:
import unreal # type: ignore
except ImportError:
unreal = None
MATERIAL_PROPERTY_MAP = {
"basecolor": "MP_BASE_COLOR",
"base_color": "MP_BASE_COLOR",
"metallic": "MP_METALLIC",
"specular": "MP_SPECULAR",
"roughness": "MP_ROUGHNESS",
"emissive": "MP_EMISSIVE_COLOR",
"emissivecolor": "MP_EMISSIVE_COLOR",
"emissive_color": "MP_EMISSIVE_COLOR",
"opacity": "MP_OPACITY",
"opacitymask": "MP_OPACITY_MASK",
"opacity_mask": "MP_OPACITY_MASK",
"normal": "MP_NORMAL",
"worldpositionoffset": "MP_WORLD_POSITION_OFFSET",
"world_position_offset": "MP_WORLD_POSITION_OFFSET",
"ambientocclusion": "MP_AMBIENT_OCCLUSION",
"ambient_occlusion": "MP_AMBIENT_OCCLUSION",
"refraction": "MP_REFRACTION",
"tangent": "MP_TANGENT",
"displacement": "MP_DISPLACEMENT",
"subsurfacecolor": "MP_SUBSURFACE_COLOR",
"subsurface_color": "MP_SUBSURFACE_COLOR",
"customizeduvs0": "MP_CUSTOMIZED_UV_0",
"customizeduv0": "MP_CUSTOMIZED_UV_0",
"customizeduvs1": "MP_CUSTOMIZED_UV_1",
"customizeduv1": "MP_CUSTOMIZED_UV_1",
"customizeduvs2": "MP_CUSTOMIZED_UV_2",
"customizeduv2": "MP_CUSTOMIZED_UV_2",
"customizeduvs3": "MP_CUSTOMIZED_UV_3",
"customizeduv3": "MP_CUSTOMIZED_UV_3",
"customizeduvs4": "MP_CUSTOMIZED_UV_4",
"customizeduv4": "MP_CUSTOMIZED_UV_4",
"customizeduvs5": "MP_CUSTOMIZED_UV_5",
"customizeduv5": "MP_CUSTOMIZED_UV_5",
"customizeduvs6": "MP_CUSTOMIZED_UV_6",
"customizeduv6": "MP_CUSTOMIZED_UV_6",
"customizeduvs7": "MP_CUSTOMIZED_UV_7",
"customizeduv7": "MP_CUSTOMIZED_UV_7",
}
def _asset_tools():
return unreal.AssetToolsHelpers.get_asset_tools()
def _mel():
lib = getattr(unreal, "MaterialEditingLibrary", None)
if lib is None:
raise RuntimeError("unreal.MaterialEditingLibrary is unavailable. Enable the Material Editor scripting support in UE.")
return lib
def _split_pkg(path: str):
if "/" not in path:
raise RuntimeError(f"bad asset path {path!r}")
return path.rsplit("/", 1)
def _load_asset(path: str):
asset = unreal.EditorAssetLibrary.load_asset(path)
if asset is None:
try:
asset = unreal.load_asset(path)
except Exception:
asset = None
if asset is None and "." not in path.rsplit("/", 1)[-1]:
try:
object_path = path + "." + path.rsplit("/", 1)[-1]
asset = unreal.load_asset(object_path)
except Exception:
asset = None
if asset is None:
try:
for selected_asset in unreal.EditorUtilityLibrary.get_selected_assets():
if selected_asset.get_path_name().startswith(path):
asset = selected_asset
break
except Exception:
pass
if asset is None:
raise RuntimeError(f"asset not found: {path}")
return asset
def _load_material(path: str):
asset = _load_asset(path)
if not isinstance(asset, unreal.Material):
raise RuntimeError(f"{path} is not a Material ({type(asset).__name__})")
return asset
def _load_material_function(path: str):
asset = _load_asset(path)
if not isinstance(asset, unreal.MaterialFunction):
raise RuntimeError(f"{path} is not a MaterialFunction ({type(asset).__name__})")
return asset
def _load_material_interface(path: str):
asset = _load_asset(path)
if not isinstance(asset, unreal.MaterialInterface):
raise RuntimeError(f"{path} is not a MaterialInterface ({type(asset).__name__})")
return asset
def _load_expression_class(path_or_name: str):
if not path_or_name:
raise RuntimeError("expression class is required")
candidates = [path_or_name]
if "/" not in path_or_name:
name = path_or_name
if not name.startswith("MaterialExpression"):
candidates.append("MaterialExpression" + name)
candidates.extend([
"/Script/Engine." + name,
"/Script/Engine.MaterialExpression" + name,
])
for candidate in candidates:
cls = unreal.load_class(None, candidate) if "/" in candidate else None
if cls is not None:
return cls
raise RuntimeError(f"material expression class not found: {path_or_name!r}")
def _material_expressions(asset):
lib = getattr(unreal, "MCPMaterialLibrary", None)
if lib is not None:
try:
return list(lib.get_material_expressions(asset) or []), "MCPMaterialLibrary"
except Exception:
pass
for prop in ("expressions", "function_expressions"):
try:
expressions = asset.get_editor_property(prop)
if expressions is not None:
return list(expressions), prop
except Exception:
pass
return [], ""
def _find_expression(asset, ident: str):
if not ident:
raise RuntimeError("node guid/name is required")
lib = getattr(unreal, "MCPMaterialLibrary", None)
if lib is not None:
try:
expr = lib.find_material_expression(asset, ident)
if expr is not None:
return expr
except Exception:
pass
expressions, _ = _material_expressions(asset)
for expr in expressions:
if _expr_guid(expr) == ident or expr.get_name() == ident:
return expr
raise RuntimeError(f"material expression not found: {ident}")
def _connect_material_expressions(asset, from_expr, from_output: str, to_expr, to_input: str) -> dict:
try:
ok = _mel().connect_material_expressions(from_expr, str(from_output or ""), to_expr, str(to_input or ""))
if ok:
return {"ok": True, "method": "MaterialEditingLibrary"}
except Exception as e:
mel_error = str(e)
else:
mel_error = "MaterialEditingLibrary.connect_material_expressions returned false"
lib = getattr(unreal, "MCPMaterialLibrary", None)
if lib is None or not hasattr(lib, "connect_material_expressions_raw"):
return {"ok": False, "error": mel_error}
try:
raw = lib.connect_material_expressions_raw(asset, from_expr, str(from_output or ""), to_expr, str(to_input or ""))
result = json.loads(raw) if isinstance(raw, str) else dict(raw)
if result.get("ok"):
result.setdefault("method", "MCPMaterialLibrary.ConnectMaterialExpressionsRaw")
return result
result.setdefault("error", mel_error)
return result
except Exception as e:
return {"ok": False, "error": f"{mel_error}; raw fallback failed: {e}"}
def _expr_guid(expr) -> str:
try:
guid = expr.get_editor_property("material_expression_editor_x_guid")
if guid:
return str(guid)
except Exception:
pass
try:
return str(expr.get_outer().get_path_name()) + ":" + expr.get_name()
except Exception:
return expr.get_name()
def _expr_pos(expr):
try:
x, y = 0, 0
_mel().get_material_expression_node_position(expr, x, y)
except Exception:
pass
try:
return [int(expr.get_editor_property("material_expression_editor_x")),
int(expr.get_editor_property("material_expression_editor_y"))]
except Exception:
return [0, 0]
def _material_property(name: str):
enum_name = MATERIAL_PROPERTY_MAP.get(str(name).replace(" ", "").lower(), str(name))
try:
return getattr(unreal.MaterialProperty, enum_name)
except Exception:
pass
if not str(enum_name).startswith("MP_"):
try:
return getattr(unreal.MaterialProperty, "MP_" + str(enum_name).upper())
except Exception:
pass
raise RuntimeError(f"unknown material property {name!r}")
def _coerce_value(value, current=None):
# The loosely-typed "value" tool field arrives JSON-stringified; unwrap it.
if isinstance(value, str):
s = value.strip()
if current is not None and isinstance(current, bool):
if s.lower() in ("true", "1"):
return True
if s.lower() in ("false", "0"):
return False
if current is not None and isinstance(current, float):
try:
return float(s)
except Exception:
pass
if current is not None and isinstance(current, int) and not isinstance(current, bool):
try:
return int(float(s))
except Exception:
pass
if s[:1] in ("[", "{"):
try:
import json
return _coerce_value(json.loads(s), current)
except Exception:
pass
if s[:1] == "(" and "=" in s:
import re
low = {k.lower(): float(v) for k, v in re.findall(r"([A-Za-z]+)\s*=\s*(-?[0-9.eE+]+)", s)}
if {"r", "g", "b"}.issubset(low):
return unreal.LinearColor(low["r"], low["g"], low["b"], low.get("a", 1.0))
if {"x", "y", "z"}.issubset(low):
return unreal.Vector(low["x"], low["y"], low["z"])
if {"x", "y"}.issubset(low):
return unreal.Vector2D(low["x"], low["y"])
if current is not None:
enum_class = getattr(unreal, type(current).__name__, None) or getattr(current, "__class__", None)
if enum_class is not None and hasattr(enum_class, s):
return getattr(enum_class, s)
try:
return float(s)
except Exception:
return value
if isinstance(value, dict):
if "asset" in value:
return _load_asset(value["asset"])
low = {str(k).lower(): v for k, v in value.items()}
if {"r", "g", "b"}.issubset(low):
return unreal.LinearColor(float(low["r"]), float(low["g"]), float(low["b"]), float(low.get("a", 1.0)))
if {"x", "y", "z"}.issubset(low):
return unreal.Vector(float(low["x"]), float(low["y"]), float(low["z"]))
if {"x", "y"}.issubset(low):
return unreal.Vector2D(float(low["x"]), float(low["y"]))
if isinstance(value, list):
if len(value) in (3, 4):
if current is not None and type(current).__name__ in ("LinearColor", "Color"):
return unreal.LinearColor(float(value[0]), float(value[1]), float(value[2]), float(value[3]) if len(value) > 3 else 1.0)
if current is not None and type(current).__name__ in ("Vector", "Vector3f"):
return unreal.Vector(float(value[0]), float(value[1]), float(value[2]))
return unreal.LinearColor(float(value[0]), float(value[1]), float(value[2]), float(value[3]) if len(value) > 3 else 1.0)
if len(value) == 2:
return unreal.Vector2D(float(value[0]), float(value[1]))
if isinstance(value, str) and current is not None:
enum_class = getattr(unreal, type(current).__name__, None) or getattr(current, "__class__", None)
if enum_class is not None and hasattr(enum_class, value):
return getattr(enum_class, value)
if isinstance(value, int) and current is not None and hasattr(current, "__class__"):
try:
return current.__class__(value)
except Exception:
pass
return value
def _set_expr_properties(expr, properties: dict):
changed = []
for key, value in (properties or {}).items():
if key.lower() == "inputs" and isinstance(expr, unreal.MaterialExpressionCustom):
custom_inputs = []
for item in value:
input_name = item.get("name") if isinstance(item, dict) else str(item)
custom_input = unreal.CustomInput()
custom_input.set_editor_property("input_name", input_name)
custom_inputs.append(custom_input)
expr.set_editor_property("inputs", custom_inputs)
changed.append(key)
continue
current = None
try:
current = expr.get_editor_property(key)
except Exception:
pass
expr.set_editor_property(key, _coerce_value(value, current))
changed.append(key)
return changed
def _create_expression(asset, spec: dict):
cls = _load_expression_class(spec.get("class") or spec.get("target") or "")
pos = spec.get("position") or [0, 0]
x = int(pos[0]) if len(pos) > 0 else 0
y = int(pos[1]) if len(pos) > 1 else 0
if isinstance(asset, unreal.Material):
expr = _mel().create_material_expression(asset, cls, x, y)
elif isinstance(asset, unreal.MaterialFunction):
expr = _mel().create_material_expression_in_function(asset, cls, x, y)
else:
raise RuntimeError(f"unsupported graph asset type {type(asset).__name__}")
if expr is None:
raise RuntimeError("create_material_expression returned None")
if spec.get("name"):
try:
expr.rename(str(spec["name"]))
except Exception:
pass
_set_expr_properties(expr, spec.get("properties") or spec.get("params") or {})
return expr
def create_material(args: dict) -> dict:
folder, name = _split_pkg(args["path"])
asset = _asset_tools().create_asset(name, folder, unreal.Material, unreal.MaterialFactoryNew())
if asset is None:
return {"ok": False, "error": "create_asset returned None"}
unreal.EditorAssetLibrary.save_loaded_asset(asset)
return {"ok": True, "path": asset.get_path_name().split(".")[0]}
def set_material_settings(args: dict) -> dict:
asset = _load_material(args["asset_path"])
changed = []
for key, value in (args.get("properties") or {}).items():
current = None
try:
current = asset.get_editor_property(key)
except Exception:
pass
asset.set_editor_property(key, _coerce_value(value, current))
changed.append(key)
_update_asset(asset)
unreal.EditorAssetLibrary.save_loaded_asset(asset)
return {"ok": True, "changed": changed}
def inspect_material_settings(args: dict) -> dict:
asset = _load_material(args["asset_path"])
result = {"ok": True, "properties": {}}
for key in args.get("properties") or []:
value = asset.get_editor_property(key)
result["properties"][key] = {
"type": type(value).__name__,
"repr": repr(value),
"str": str(value),
"members": list(getattr(value.__class__, "__members__", {}).keys()),
"unreal_class": repr(getattr(unreal, type(value).__name__, None)),
"class_dir": [n for n in dir(value.__class__) if n.startswith("BL_") or n.startswith("MD_")],
}
return result
def create_material_instance(args: dict) -> dict:
folder, name = _split_pkg(args["path"])
asset = _asset_tools().create_asset(name, folder, unreal.MaterialInstanceConstant, unreal.MaterialInstanceConstantFactoryNew())
if asset is None:
return {"ok": False, "error": "create_asset returned None"}
parent = args.get("parent")
if parent:
_mel().set_material_instance_parent(asset, _load_material_interface(parent))
unreal.EditorAssetLibrary.save_loaded_asset(asset)
return {"ok": True, "path": asset.get_path_name().split(".")[0], "parent": parent or None}
def read_graph(args: dict) -> dict:
asset_path = args["asset_path"]
asset = _load_asset(asset_path)
lib = getattr(unreal, "MCPMaterialLibrary", None)
if lib is not None:
try:
return json.loads(lib.dump_material_graph_to_json(asset))
except Exception:
pass
expressions, _ = _material_expressions(asset)
nodes = []
edges = []
for expr in expressions:
node = {
"guid": _expr_guid(expr),
"name": expr.get_name(),
"class": expr.get_class().get_path_name(),
"position": _expr_pos(expr),
"inputs": [],
}
try:
node["input_names"] = list(_mel().get_material_expression_input_names(expr))
except Exception:
node["input_names"] = []
nodes.append(node)
if isinstance(asset, unreal.Material):
try:
inputs = list(_mel().get_inputs_for_material_expression(asset, expr))
for input_expr in inputs:
out_name = ""
try:
out_name = str(_mel().get_input_node_output_name_for_material_expression(expr, input_expr))
except Exception:
pass
edges.append({
"from": [_expr_guid(input_expr), out_name],
"to": [_expr_guid(expr), ""],
})
except Exception:
pass
outputs = {}
if isinstance(asset, unreal.Material):
for pretty, enum_name in MATERIAL_PROPERTY_MAP.items():
if pretty != pretty.replace("_", ""):
continue
try:
prop = getattr(unreal.MaterialProperty, enum_name)
input_node = _mel().get_material_property_input_node(asset, prop)
if input_node is not None:
outputs[pretty] = {
"from": [_expr_guid(input_node), str(_mel().get_material_property_input_node_output_name(asset, prop))],
}
except Exception:
pass
return {"ok": True, "asset_path": asset_path, "nodes": nodes, "edges": edges, "outputs": outputs}
def apply_graph(args: dict) -> dict:
asset_path = args["asset_path"]
asset = _load_asset(asset_path)
if not isinstance(asset, (unreal.Material, unreal.MaterialFunction)):
return {"ok": False, "error": f"{asset_path} is not a Material or MaterialFunction"}
result = {"ok": False, "asset_path": asset_path, "spawned": {}, "errors": [], "warnings": []}
if args.get("clear_existing"):
if isinstance(asset, unreal.Material):
_mel().delete_all_material_expressions(asset)
else:
_mel().delete_all_material_expressions_in_function(asset)
local_map = {}
node_specs = graph_layout.normalize_specs(
args.get("nodes") or [],
args.get("edges") or [],
domain="material",
) if args.get("auto_layout", True) else list(args.get("nodes") or [])
for spec in node_specs:
try:
expr = _create_expression(asset, spec)
local_id = spec.get("id") or expr.get_name()
guid = _expr_guid(expr)
local_map[local_id] = expr
result["spawned"][local_id] = {"guid": guid, "name": expr.get_name(), "class": expr.get_class().get_path_name()}
except Exception as e:
result["errors"].append(f"node {spec.get('id') or spec.get('class')}: {e}")
def resolve_node(ref):
if ref in local_map:
return local_map[ref]
return _find_expression(asset, ref)
for edge in args.get("edges") or []:
try:
from_ref, from_output = edge["from"]
to_ref, to_input = edge["to"]
connect_result = _connect_material_expressions(asset, resolve_node(from_ref), str(from_output or ""), resolve_node(to_ref), str(to_input or ""))
if not connect_result.get("ok"):
result["warnings"].append(f"connect returned false: {edge}: {connect_result.get('error') or connect_result}")
except Exception as e:
result["errors"].append(f"edge {edge}: {e}")
if isinstance(asset, unreal.Material):
for prop_name, out_ref in (args.get("outputs") or {}).items():
try:
if isinstance(out_ref, dict):
out_ref = out_ref.get("from")
from_ref, from_output = out_ref
ok = _mel().connect_material_property(resolve_node(from_ref), str(from_output or ""), _material_property(prop_name))
if not ok:
result["warnings"].append(f"connect output returned false: {prop_name}")
except Exception as e:
result["errors"].append(f"output {prop_name}: {e}")
if args.get("layout", False):
_layout_asset(asset)
_update_asset(asset)
if args.get("save", True):
unreal.EditorAssetLibrary.save_loaded_asset(asset)
result["ok"] = not result["errors"]
return result
def add_node(args: dict) -> dict:
asset = _load_asset(args["asset_path"])
expr = _create_expression(asset, args)
_update_asset(asset)
unreal.EditorAssetLibrary.save_loaded_asset(asset)
return {"ok": True, "guid": _expr_guid(expr), "name": expr.get_name(), "class": expr.get_class().get_path_name()}
def delete_node(args: dict) -> dict:
asset = _load_asset(args["asset_path"])
expr = _find_expression(asset, args.get("guid") or args.get("name"))
if isinstance(asset, unreal.Material):
_mel().delete_material_expression(asset, expr)
elif isinstance(asset, unreal.MaterialFunction):
_mel().delete_material_expression_in_function(asset, expr)
else:
raise RuntimeError("asset is not Material or MaterialFunction")
_update_asset(asset)
unreal.EditorAssetLibrary.save_loaded_asset(asset)
return {"ok": True}
def move_nodes(args: dict) -> dict:
asset = _load_asset(args["asset_path"])
moved = []
for move in args.get("moves") or []:
expr = _find_expression(asset, move.get("guid") or move.get("name"))
expr.set_editor_property("material_expression_editor_x", int(move["x"]))
expr.set_editor_property("material_expression_editor_y", int(move["y"]))
moved.append(_expr_guid(expr))
_update_asset(asset)
unreal.EditorAssetLibrary.save_loaded_asset(asset)
return {"ok": True, "moved": moved}
def set_node_property(args: dict) -> dict:
asset = _load_asset(args["asset_path"])
expr = _find_expression(asset, args.get("guid") or args.get("name"))
changed = _set_expr_properties(expr, {args["property"]: args.get("value")})
_update_asset(asset)
unreal.EditorAssetLibrary.save_loaded_asset(asset)
return {"ok": True, "changed": changed}
def connect(args: dict) -> dict:
asset = _load_asset(args["asset_path"])
from_ref, from_output = args["from"]
to_ref, to_input = args["to"]
result = _connect_material_expressions(
asset,
_find_expression(asset, from_ref),
str(from_output or ""),
_find_expression(asset, to_ref),
str(to_input or ""),
)
_update_asset(asset)
unreal.EditorAssetLibrary.save_loaded_asset(asset)
return result
def connect_output(args: dict) -> dict:
asset = _load_material(args["asset_path"])
from_ref, from_output = args["from"]
ok = _mel().connect_material_property(_find_expression(asset, from_ref), str(from_output or ""), _material_property(args["property"]))
_update_asset(asset)
unreal.EditorAssetLibrary.save_loaded_asset(asset)
return {"ok": bool(ok)}
def layout(args: dict) -> dict:
asset = _load_asset(args["asset_path"])
_layout_asset(asset)
unreal.EditorAssetLibrary.save_loaded_asset(asset)
return {"ok": True}
def tidy_graph(args: dict) -> dict:
asset = _load_asset(args["asset_path"])
graph = read_graph({"asset_path": args["asset_path"]})
if not graph.get("ok"):
return graph
moves = graph_layout.tidy_existing_nodes(graph.get("nodes") or [], graph.get("edges") or [], "material")
moved = []
for move in moves:
expr = _find_expression(asset, move["guid"])
expr.set_editor_property("material_expression_editor_x", int(move["x"]))
expr.set_editor_property("material_expression_editor_y", int(move["y"]))
moved.append(move)
_update_asset(asset)
unreal.EditorAssetLibrary.save_loaded_asset(asset)
return {"ok": True, "moved": moved, "count": len(moved)}
def recompile_save(args: dict) -> dict:
asset = _load_asset(args["asset_path"])
_update_asset(asset)
unreal.EditorAssetLibrary.save_loaded_asset(asset)
return {"ok": True}
def set_instance_parent(args: dict) -> dict:
asset = _load_asset(args["asset_path"])
if not isinstance(asset, unreal.MaterialInstanceConstant):
return {"ok": False, "error": "asset is not MaterialInstanceConstant"}
_mel().set_material_instance_parent(asset, _load_material_interface(args["parent"]))
unreal.EditorAssetLibrary.save_loaded_asset(asset)
return {"ok": True}
def set_instance_scalar(args: dict) -> dict:
asset = _load_asset(args["asset_path"])
ok = _mel().set_material_instance_scalar_parameter_value(asset, str(args["name"]), float(args["value"]))
unreal.EditorAssetLibrary.save_loaded_asset(asset)
return {"ok": bool(ok)}
def set_instance_vector(args: dict) -> dict:
asset = _load_asset(args["asset_path"])
ok = _mel().set_material_instance_vector_parameter_value(asset, str(args["name"]), _coerce_value(args["value"]))
unreal.EditorAssetLibrary.save_loaded_asset(asset)
return {"ok": bool(ok)}
def set_instance_texture(args: dict) -> dict:
asset = _load_asset(args["asset_path"])
ok = _mel().set_material_instance_texture_parameter_value(asset, str(args["name"]), _load_asset(args["value"]))
unreal.EditorAssetLibrary.save_loaded_asset(asset)
return {"ok": bool(ok)}
def list_parameters(args: dict) -> dict:
asset = _load_material_interface(args["asset_path"])
out = {"ok": True, "asset_path": args["asset_path"], "scalar": [], "vector": [], "texture": [], "static_switch": []}
for key, fn_name in (
("scalar", "get_scalar_parameter_names"),
("vector", "get_vector_parameter_names"),
("texture", "get_texture_parameter_names"),
("static_switch", "get_static_switch_parameter_names"),
):
try:
out[key] = [str(x) for x in getattr(_mel(), fn_name)(asset)]
except Exception as e:
out[key + "_error"] = str(e)
return out
def open_asset(args: dict) -> dict:
asset = _load_asset(args["asset_path"])
ok = unreal.AssetEditorSubsystem().open_editor_for_assets([asset])
return {"ok": bool(ok), "asset_path": args["asset_path"]}
def _layout_asset(asset):
if isinstance(asset, unreal.Material):
_mel().layout_material_expressions(asset)
elif isinstance(asset, unreal.MaterialFunction):
_mel().layout_material_function_expressions(asset)
def _update_asset(asset):
if isinstance(asset, unreal.Material):
_mel().recompile_material(asset)
elif isinstance(asset, unreal.MaterialFunction):
_mel().update_material_function(asset, None)
elif isinstance(asset, unreal.MaterialInstanceConstant):
_mel().update_material_instance(asset)
OPS = {
"create_material": create_material,
"set_material_settings": set_material_settings,
"inspect_material_settings": inspect_material_settings,
"create_material_instance": create_material_instance,
"read_graph": read_graph,
"apply_graph": apply_graph,
"add_node": add_node,
"delete_node": delete_node,
"move_nodes": move_nodes,
"set_node_property": set_node_property,
"connect": connect,
"connect_output": connect_output,
"layout": layout,
"tidy_graph": tidy_graph,
"recompile_save": recompile_save,
"set_instance_parent": set_instance_parent,
"set_instance_scalar": set_instance_scalar,
"set_instance_vector": set_instance_vector,
"set_instance_texture": set_instance_texture,
"list_parameters": list_parameters,
"open_asset": open_asset,
}
def entrypoint(payload_json: str) -> str:
try:
p = json.loads(payload_json)
except Exception as e:
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
fn = OPS.get(p.get("op"))
if not fn:
return _tee({"ok": False, "error": f"unknown op {p.get('op')!r}", "available": list(OPS)}, p)
try:
return _tee(fn(p), p)
except Exception as e:
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
def _tee(result: dict, p: dict) -> str:
txt = json.dumps(result)
try:
if unreal is not None:
sd = unreal.Paths.project_saved_dir() + "MCP/"
os.makedirs(sd, exist_ok=True)
rid = p.get("__rid") or "last"
with open(sd + "last.json", "w", encoding="utf-8") as f:
f.write(txt)
if rid != "last":
with open(sd + f"{rid}.json", "w", encoding="utf-8") as f:
f.write(txt)
unreal.log("[MCP-MATERIALS] " + txt[:1500])
except Exception:
pass
return txt

361
ue_side/mesh.py Normal file
View File

@ -0,0 +1,361 @@
"""Static / Skeletal Mesh ops — info, LODs, sockets, collision.
Ops:
static_info {path} — LODs, materials, collision, sockets, bounds, vertex/tri count
skeletal_info {path} — LODs, bones, sockets, skeleton, physics asset
list_sockets {path} — works for both static and skeletal
add_socket {path, name, bone?, location?, rotation?, scale?}
remove_socket {path, name}
list_lods {path}
set_lod_screen_size {path, lod_index, screen_size}
remove_lod {path, lod_index}
set_collision_complexity{path, complexity:'Default|UseSimpleAsComplex|UseComplexAsSimple'}
add_simple_collision {path, shape:'Box|Sphere|Capsule', extents_or_radius}
"""
from __future__ import annotations
import json
import os
import traceback
try:
import unreal # type: ignore
except ImportError:
unreal = None
def _load(p): return unreal.EditorAssetLibrary.load_asset(p)
def _vec(v, d=(0, 0, 0)):
if v is None: v = d
return unreal.Vector(float(v[0]), float(v[1]), float(v[2]))
def _rot(v, d=(0, 0, 0)):
if v is None: v = d
return unreal.Rotator(float(v[0]), float(v[1]), float(v[2]))
def static_info(args):
m = _load(args["path"])
if m is None or not isinstance(m, unreal.StaticMesh):
return {"ok": False, "error": "not a StaticMesh"}
out = {"ok": True, "path": args["path"]}
# UE 5.4+ moved methods from EditorStaticMeshLibrary to StaticMeshEditorSubsystem.
smes = unreal.get_editor_subsystem(unreal.StaticMeshEditorSubsystem) if hasattr(unreal, "StaticMeshEditorSubsystem") else None
legacy = unreal.EditorStaticMeshLibrary if hasattr(unreal, "EditorStaticMeshLibrary") else None
def _call(*sources, **kw):
method = kw["method"]; args_ = kw.get("args", ())
for src in sources:
if src is None: continue
fn = getattr(src, method, None)
if fn:
try: return fn(m, *args_)
except Exception: pass
return None
try:
v = _call(smes, legacy, method="get_lod_count")
if v is not None: out["lod_count"] = v
v = _call(smes, legacy, method="get_number_verts", args=(0,))
if v is not None: out["vertex_count"] = v
v = _call(smes, legacy, method="get_number_triangles", args=(0,))
if v is not None: out["triangle_count"] = v
slots = _call(smes, legacy, method="get_lod_material_slot_names", args=(0,))
if slots: out["material_slots"] = [str(n) for n in slots]
b = _call(smes, legacy, method="get_bounds")
if b is not None:
try: out["bounds"] = list(b.box_extent)
except Exception: pass
except Exception as e:
out["info_error"] = str(e)
try:
smes = unreal.get_editor_subsystem(unreal.StaticMeshEditorSubsystem) if hasattr(unreal, "StaticMeshEditorSubsystem") else None
out["sockets"] = [str(n) for n in (smes.get_sockets(m) if smes else [])]
except Exception:
out["sockets"] = []
try:
out["materials"] = [{"slot": str(s.material_slot_name),
"material": s.material_interface.get_path_name() if s.material_interface else None}
for s in (m.get_editor_property("static_materials") or [])]
except Exception:
pass
return out
def skeletal_info(args):
m = _load(args["path"])
if m is None:
return {"ok": False, "error": "not found"}
out = {"ok": True, "path": args["path"], "class": m.get_class().get_name()}
try:
sk = m.get_editor_property("skeleton")
out["skeleton"] = sk.get_path_name().split(".")[0] if sk else None
except Exception:
pass
try:
pa = m.get_editor_property("physics_asset")
out["physics_asset"] = pa.get_path_name().split(".")[0] if pa else None
except Exception:
pass
try:
ssel = unreal.EditorSkeletalMeshLibrary if hasattr(unreal, "EditorSkeletalMeshLibrary") else None
if ssel:
out["lod_count"] = ssel.get_lod_count(m)
except Exception:
pass
try:
out["sockets"] = [str(s.socket_name) for s in (m.get_editor_property("sockets") or [])]
except Exception:
out["sockets"] = []
try:
mats = m.get_editor_property("materials") or []
out["materials"] = [(str(x.material_slot_name), x.material_interface.get_path_name() if x.material_interface else None) for x in mats]
except Exception:
pass
return out
def list_sockets(args):
m = _load(args["path"])
if m is None:
return {"ok": False, "error": "not found"}
if isinstance(m, unreal.StaticMesh):
out = []
# Sockets array is protected — use StaticMeshEditorSubsystem or find_socket fallback.
names = []
smes = unreal.get_editor_subsystem(unreal.StaticMeshEditorSubsystem) if hasattr(unreal, "StaticMeshEditorSubsystem") else None
if smes and hasattr(smes, "get_sockets"):
try: names = [str(n) for n in (smes.get_sockets(m) or [])]
except Exception: names = []
for n in names:
try:
s = m.find_socket(n)
if s:
out.append({"name": n,
"location": list(s.get_editor_property("relative_location")),
"rotation": list(s.get_editor_property("relative_rotation"))})
except Exception:
out.append({"name": n})
else:
out = []
for s in (m.get_editor_property("sockets") or []):
try:
out.append({"name": str(s.get_editor_property("socket_name")),
"bone": str(s.get_editor_property("bone_name")),
"location": list(s.get_editor_property("relative_location")),
"rotation": list(s.get_editor_property("relative_rotation"))})
except Exception:
pass
return {"ok": True, "sockets": out}
def add_socket(args):
m = _load(args["path"])
if m is None:
return {"ok": False, "error": "not found"}
if isinstance(m, unreal.StaticMesh):
# The Sockets array is a protected UPROPERTY in Python — fall back to
# StaticMeshEditorSubsystem which exposes set_socket / set_sockets
# depending on UE version. If neither is exposed, instruct user to use UI.
smes = unreal.get_editor_subsystem(unreal.StaticMeshEditorSubsystem) if hasattr(unreal, "StaticMeshEditorSubsystem") else None
sock = unreal.StaticMeshSocket()
sock.set_editor_property("socket_name", args["name"])
sock.set_editor_property("relative_location", _vec(args.get("location")))
sock.set_editor_property("relative_rotation", _rot(args.get("rotation")))
sock.set_editor_property("relative_scale", _vec(args.get("scale"), (1, 1, 1)))
added = False
if smes:
for fn_name in ("set_socket", "add_socket"):
fn = getattr(smes, fn_name, None)
if fn:
try: fn(m, sock); added = True; break
except Exception: pass
if not added:
return {"ok": False, "error": "StaticMesh sockets array is protected; StaticMeshEditorSubsystem has no set_socket/add_socket on this build. Use Static Mesh Editor UI."}
else:
sock = unreal.SkeletalMeshSocket()
sock.set_editor_property("socket_name", args["name"])
sock.set_editor_property("bone_name", args.get("bone") or "")
sock.set_editor_property("relative_location", _vec(args.get("location")))
sock.set_editor_property("relative_rotation", _rot(args.get("rotation")))
sockets = list(m.get_editor_property("sockets") or [])
sockets.append(sock)
m.set_editor_property("sockets", sockets)
unreal.EditorAssetLibrary.save_asset(m.get_path_name())
return {"ok": True}
def remove_socket(args):
m = _load(args["path"])
if m is None:
return {"ok": False, "error": "not found"}
target = args["name"]
if isinstance(m, unreal.StaticMesh):
smes = unreal.get_editor_subsystem(unreal.StaticMeshEditorSubsystem) if hasattr(unreal, "StaticMeshEditorSubsystem") else None
removed = False
if smes:
for fn_name in ("remove_socket",):
fn = getattr(smes, fn_name, None)
if fn:
try: fn(m, target); removed = True; break
except Exception: pass
if not removed:
return {"ok": False, "error": "StaticMesh sockets array is protected; no remove_socket on subsystem in this build. Use UI."}
else:
m.set_editor_property("sockets",
[s for s in (m.get_editor_property("sockets") or [])
if str(s.get_editor_property("socket_name")) != target])
unreal.EditorAssetLibrary.save_asset(m.get_path_name())
return {"ok": True}
def list_lods(args):
m = _load(args["path"])
if m is None:
return {"ok": False, "error": "not found"}
out = []
if isinstance(m, unreal.StaticMesh):
smes = unreal.get_editor_subsystem(unreal.StaticMeshEditorSubsystem) if hasattr(unreal, "StaticMeshEditorSubsystem") else None
legacy = unreal.EditorStaticMeshLibrary if hasattr(unreal, "EditorStaticMeshLibrary") else None
def _c(method, *a):
for src in (smes, legacy):
if src is None: continue
fn = getattr(src, method, None)
if fn:
try: return fn(m, *a)
except Exception: pass
return None
n = _c("get_lod_count") or 0
for i in range(n):
lod = {"index": i}
ss = _c("get_lod_screen_size", i)
if ss is not None: lod["screen_size"] = ss
tr = _c("get_number_triangles", i)
if tr is not None: lod["tris"] = tr
out.append(lod)
else:
ssel = unreal.EditorSkeletalMeshLibrary if hasattr(unreal, "EditorSkeletalMeshLibrary") else None
sses = unreal.get_editor_subsystem(unreal.SkeletalMeshEditorSubsystem) if hasattr(unreal, "SkeletalMeshEditorSubsystem") else None
n = 0
for src in (sses, ssel):
if src and hasattr(src, "get_lod_count"):
try: n = src.get_lod_count(m); break
except Exception: pass
for i in range(n):
out.append({"index": i})
return {"ok": True, "lods": out}
def set_lod_screen_size(args):
m = _load(args["path"])
if m is None or not isinstance(m, unreal.StaticMesh):
return {"ok": False, "error": "static mesh only"}
smel = unreal.EditorStaticMeshLibrary
try:
smel.set_lod_reduction_settings if False else None
# No direct setter; use BuildSettings via set_lod_screen_size if exposed.
if hasattr(smel, "set_lod_screen_size"):
smel.set_lod_screen_size(m, int(args["lod_index"]), float(args["screen_size"]))
unreal.EditorAssetLibrary.save_asset(m.get_path_name())
return {"ok": True}
return {"ok": False, "error": "set_lod_screen_size not exposed in this UE build"}
except Exception as e:
return {"ok": False, "error": str(e)}
def remove_lod(args):
m = _load(args["path"])
if m is None or not isinstance(m, unreal.StaticMesh):
return {"ok": False, "error": "static mesh only"}
try:
unreal.EditorStaticMeshLibrary.remove_lods(m)
return {"ok": True, "note": "all generated LODs removed (UE API limitation)"}
except Exception as e:
return {"ok": False, "error": str(e)}
def set_collision_complexity(args):
m = _load(args["path"])
if m is None or not isinstance(m, unreal.StaticMesh):
return {"ok": False, "error": "static mesh only"}
mode = args["complexity"]
enum_map = {
"Default": unreal.CollisionTraceFlag.CTF_USE_DEFAULT,
"UseSimpleAsComplex": unreal.CollisionTraceFlag.CTF_USE_SIMPLE_AS_COMPLEX,
"UseComplexAsSimple": unreal.CollisionTraceFlag.CTF_USE_COMPLEX_AS_SIMPLE,
}
if mode not in enum_map:
return {"ok": False, "error": f"unknown mode; valid: {list(enum_map)}"}
try:
bs = m.get_editor_property("body_setup")
if bs:
bs.set_editor_property("collision_trace_flag", enum_map[mode])
unreal.EditorAssetLibrary.save_asset(m.get_path_name())
return {"ok": True}
return {"ok": False, "error": "body_setup missing"}
except Exception as e:
return {"ok": False, "error": str(e)}
def add_simple_collision(args):
m = _load(args["path"])
if m is None or not isinstance(m, unreal.StaticMesh):
return {"ok": False, "error": "static mesh only"}
smel = unreal.EditorStaticMeshLibrary
shape = args["shape"]
try:
if shape == "Box":
smel.add_simple_collisions(m, unreal.ScriptingCollisionShapeType.BOX)
elif shape == "Sphere":
smel.add_simple_collisions(m, unreal.ScriptingCollisionShapeType.SPHERE)
elif shape == "Capsule":
smel.add_simple_collisions(m, unreal.ScriptingCollisionShapeType.CAPSULE)
else:
return {"ok": False, "error": "shape must be Box|Sphere|Capsule"}
unreal.EditorAssetLibrary.save_asset(m.get_path_name())
return {"ok": True}
except Exception as e:
return {"ok": False, "error": str(e)}
OPS = {
"static_info": static_info,
"skeletal_info": skeletal_info,
"list_sockets": list_sockets,
"add_socket": add_socket,
"remove_socket": remove_socket,
"list_lods": list_lods,
"set_lod_screen_size": set_lod_screen_size,
"remove_lod": remove_lod,
"set_collision_complexity": set_collision_complexity,
"add_simple_collision": add_simple_collision,
}
def entrypoint(payload_json):
try:
p = json.loads(payload_json)
except Exception as e:
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
op = p.get("op"); fn = OPS.get(op)
if not fn:
return _tee({"ok": False, "error": f"unknown op {op!r}", "available": list(OPS)}, p)
try:
return _tee(fn(p), p)
except Exception as e:
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
def _tee(result, p):
txt = json.dumps(result)
try:
if unreal is not None:
sd = unreal.Paths.project_saved_dir() + "MCP/"
os.makedirs(sd, exist_ok=True)
rid = p.get("__rid") or "last"
with open(sd + "last.json", "w", encoding="utf-8") as f: f.write(txt)
if rid != "last":
with open(sd + f"{rid}.json", "w", encoding="utf-8") as f: f.write(txt)
unreal.log("[MCP-MESH] " + txt[:1500])
except Exception:
pass
return txt

302
ue_side/network.py Normal file
View File

@ -0,0 +1,302 @@
"""Network ops for Blueprint assets.
Ops:
audit_bp {bp_path}
set_actor_flags {bp_path, replicates?, replicate_movement?, net_load_on_client?,
always_relevant?, only_relevant_to_owner?, use_owner_relevancy?,
net_cull_distance_squared?, net_update_frequency?,
min_net_update_frequency?, net_priority?, net_dormancy?}
set_component_flags {bp_path, component, replicated?, net_addressable?}
set_custom_event_rpc {bp_path, function?, event?, guid?, mode, reliable?}
apply_profile {bp_path, profile}
RPC modes for set_custom_event_rpc:
none | server | client | multicast
"""
from __future__ import annotations
import json
import os
import traceback
try:
import unreal # type: ignore
except ImportError:
unreal = None
ACTOR_PROP_MAP = {
"replicates": "replicates",
"replicate_movement": "replicate_movement",
"net_load_on_client": "net_load_on_client",
"always_relevant": "always_relevant",
"only_relevant_to_owner": "only_relevant_to_owner",
"use_owner_relevancy": "net_use_owner_relevancy",
"net_cull_distance_squared": "net_cull_distance_squared",
"net_update_frequency": "net_update_frequency",
"min_net_update_frequency": "min_net_update_frequency",
"net_priority": "net_priority",
"net_dormancy": "net_dormancy",
}
def _load_bp(path: str):
bp = unreal.EditorAssetLibrary.load_asset(path)
if not isinstance(bp, unreal.Blueprint):
raise RuntimeError(f"not a Blueprint: {path!r}")
return bp
def _generated_class(bp_path: str):
cls_path = f"{bp_path}.{bp_path.rsplit('/', 1)[-1]}_C"
cls = unreal.load_class(None, cls_path)
if cls is None:
raise RuntimeError(f"generated class not found: {cls_path}")
return cls
def _cdo(bp_path: str):
return unreal.get_default_object(_generated_class(bp_path))
def _lib():
L = getattr(unreal, "MCPGraphLibrary", None)
if L is None:
raise RuntimeError("MCPGraphLibrary missing - rebuild C++ plugin")
return L
def _find_graph(bp, name: str):
L = _lib()
if name in (None, "", "EventGraph"):
return L.find_event_graph(bp)
return L.find_function_graph(bp, name)
def _get_prop(obj, prop):
try:
return obj.get_editor_property(prop)
except Exception:
return getattr(obj, prop)
def _set_prop(obj, prop, value):
try:
obj.set_editor_property(prop, value)
except Exception:
setattr(obj, prop, value)
def _save_compile(bp):
L = getattr(unreal, "MCPGraphLibrary", None)
messages = []
try:
if L:
messages = list(L.compile_with_messages(bp))
else:
unreal.KismetEditorUtilities.compile_blueprint(bp)
except Exception as e:
messages = [f"WARN||compile failed from network.py: {e}"]
unreal.EditorAssetLibrary.save_loaded_asset(bp)
return messages
def _read_actor_flags(cdo):
out = {}
for public_name, prop in ACTOR_PROP_MAP.items():
try:
v = _get_prop(cdo, prop)
out[public_name] = str(v) if public_name == "net_dormancy" else v
except Exception as e:
out[public_name] = {"error": str(e)}
return out
def audit_bp(args: dict) -> dict:
bp_path = args["bp_path"]
cdo = _cdo(bp_path)
result = {
"ok": True,
"bp_path": bp_path,
"actor": _read_actor_flags(cdo),
"components": [],
"custom_events": [],
"notes": [],
}
for name in args.get("components") or []:
item = {"name": name}
try:
comp = _get_prop(cdo, name)
item["class"] = comp.get_class().get_name()
for prop in ("replicates", "is_replicated", "net_addressable"):
try:
item[prop] = _get_prop(comp, prop)
except Exception:
pass
except Exception as e:
item["error"] = str(e)
result["components"].append(item)
try:
import apply_graph
raw = apply_graph.read_graph(bp_path, args.get("function") or "EventGraph")
for node in raw.get("graph", {}).get("nodes", []):
if node.get("class") == "K2Node_CustomEvent":
result["custom_events"].append({
"guid": node.get("guid"),
"title": node.get("title"),
"target": node.get("target"),
})
result["notes"].append("Custom event RPC flags require set_custom_event_rpc; read_graph does not expose them.")
except Exception as e:
result["notes"].append(f"custom event scan failed: {e}")
return result
def set_actor_flags(args: dict) -> dict:
bp_path = args["bp_path"]
bp = _load_bp(bp_path)
cdo = _cdo(bp_path)
changed = {}
errors = {}
for public_name, prop in ACTOR_PROP_MAP.items():
if public_name not in args:
continue
try:
_set_prop(cdo, prop, args[public_name])
changed[public_name] = args[public_name]
except Exception as e:
errors[public_name] = str(e)
messages = _save_compile(bp)
return {"ok": not errors, "bp_path": bp_path, "changed": changed, "errors": errors, "compile_messages": messages}
def _component_from_cdo(cdo, name: str):
try:
return _get_prop(cdo, name)
except Exception:
pass
getter = getattr(cdo, "get_component_by_class", None)
if getter:
# Fallback is intentionally conservative: caller should pass the BP component variable name.
return None
return None
def set_component_flags(args: dict) -> dict:
bp_path = args["bp_path"]
bp = _load_bp(bp_path)
cdo = _cdo(bp_path)
comp_name = args["component"]
comp = _component_from_cdo(cdo, comp_name)
if comp is None:
return {"ok": False, "error": f"component not found on CDO by name: {comp_name}"}
changed = {}
errors = {}
for public_name, candidates in {
"replicated": ("replicates", "is_replicated"),
"net_addressable": ("net_addressable",),
}.items():
if public_name not in args:
continue
done = False
for prop in candidates:
try:
_set_prop(comp, prop, args[public_name])
changed[public_name] = args[public_name]
done = True
break
except Exception:
continue
if not done:
errors[public_name] = f"no writable property among {candidates}"
messages = _save_compile(bp)
return {"ok": not errors, "bp_path": bp_path, "component": comp_name, "changed": changed, "errors": errors, "compile_messages": messages}
def set_custom_event_rpc(args: dict) -> dict:
bp_path = args["bp_path"]
bp = _load_bp(bp_path)
graph = _find_graph(bp, args.get("function") or "EventGraph")
if graph is None:
return {"ok": False, "error": "graph not found"}
target = args.get("guid") or args.get("event")
if not target:
return {"ok": False, "error": "set_custom_event_rpc requires guid or event"}
mode = args.get("mode") or "none"
reliable = bool(args.get("reliable", False))
L = _lib()
setter = getattr(L, "set_custom_event_network_flags", None)
if setter is None:
return {
"ok": False,
"error": "MCPGraphLibrary.set_custom_event_network_flags is unavailable. Rebuild/restart UE after the plugin C++ update.",
"requested": {"target": target, "mode": mode, "reliable": reliable},
}
ok = bool(setter(bp, graph, target, mode, reliable))
messages = _save_compile(bp) if ok else []
return {"ok": ok, "bp_path": bp_path, "target": target, "mode": mode, "reliable": reliable, "compile_messages": messages}
def apply_profile(args: dict) -> dict:
profile = args["profile"]
bp_path = args["bp_path"]
if profile == "replicated_world_actor":
return set_actor_flags({
"bp_path": bp_path,
"replicates": True,
"net_load_on_client": True,
"replicate_movement": bool(args.get("replicate_movement", False)),
})
if profile == "always_relevant_world_actor":
return set_actor_flags({
"bp_path": bp_path,
"replicates": True,
"net_load_on_client": True,
"always_relevant": True,
"replicate_movement": bool(args.get("replicate_movement", False)),
})
return {"ok": False, "error": f"unknown profile {profile!r}", "available": ["replicated_world_actor", "always_relevant_world_actor"]}
OPS = {
"audit_bp": audit_bp,
"set_actor_flags": set_actor_flags,
"set_component_flags": set_component_flags,
"set_custom_event_rpc": set_custom_event_rpc,
"apply_profile": apply_profile,
}
def entrypoint(payload_json: str) -> str:
try:
p = json.loads(payload_json)
except Exception as e:
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
fn = OPS.get(p.get("op"))
if not fn:
return _tee({"ok": False, "error": f"unknown op {p.get('op')!r}", "available": list(OPS)}, p)
try:
return _tee(fn(p), p)
except Exception as e:
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
def _tee(result: dict, p: dict) -> str:
txt = json.dumps(result)
try:
if unreal is not None:
sd = unreal.Paths.project_saved_dir() + "MCP/"
os.makedirs(sd, exist_ok=True)
rid = p.get("__rid") or "last"
with open(sd + f"{rid}.json", "w", encoding="utf-8") as f:
f.write(txt)
unreal.log("[MCP-NETWORK] " + txt[:1500])
except Exception:
pass
return txt

271
ue_side/niagara.py Normal file
View File

@ -0,0 +1,271 @@
"""Niagara authoring ops. Bridges to unreal.MCPNiagaraLibrary (C++) where
editor-only APIs are required, falls back to pure Python where possible.
Ops:
create_system {path}
create_emitter_asset {path, template_emitter?} — create empty UNiagaraEmitter asset
add_emitter {system, emitter_asset, name?} — add emitter to system, returns handle name
remove_emitter {system, emitter_name}
list_emitters {system}
list_modules {system, emitter_name?, group} — group: EmitterSpawn|EmitterUpdate|ParticleSpawn|ParticleUpdate|SystemSpawn|SystemUpdate
add_module {system, emitter_name?, group, module_script}
remove_module {system, emitter_name?, group, module_name}
add_renderer {system, emitter_name, renderer_class} — e.g. NiagaraSpriteRendererProperties
remove_renderer {system, emitter_name, renderer_class}
compile {system}
set_module_input STUB — see NOTES at bottom.
"""
from __future__ import annotations
import json
import os
import traceback
try:
import unreal # type: ignore
except ImportError:
unreal = None
# ---------- loaders ----------
def _load_asset(path: str):
if not path:
return None
return unreal.EditorAssetLibrary.load_asset(path)
def _load_class(p: str):
if not p:
return None
if "/" in p:
return unreal.load_class(None, p)
# Try common Niagara paths
for prefix in ("/Script/Niagara.", "/Script/NiagaraEditor."):
c = unreal.load_class(None, prefix + p)
if c is not None:
return c
return unreal.load_class(None, p)
def _lib():
if not hasattr(unreal, "MCPNiagaraLibrary"):
raise RuntimeError("MCPNiagaraLibrary not found — rebuild the UEBlueprintMCPEditor C++ module")
return unreal.MCPNiagaraLibrary
# ---------- ops ----------
def create_system(p):
path = p["path"]
ok = _lib().create_niagara_system(path)
return {"ok": bool(ok), "path": path}
def create_emitter_asset(p):
"""Create an empty UNiagaraEmitter asset. Niagara typically doesn't support
fully empty emitters via UI — users start from a template. We create the bare
asset; for fully usable emitters duplicate an existing template via asset_op.
"""
path = p["path"]
package_name, _, asset_name = path.rpartition("/")
factory = unreal.NiagaraEmitterFactoryNew() if hasattr(unreal, "NiagaraEmitterFactoryNew") else None
tools = unreal.AssetToolsHelpers.get_asset_tools()
if factory is None:
return {"ok": False, "error": "NiagaraEmitterFactoryNew not exposed to Python — duplicate a template emitter via asset_op instead"}
asset = tools.create_asset(asset_name, package_name, unreal.NiagaraEmitter, factory)
return {"ok": asset is not None, "path": path}
def add_emitter(p):
system = _load_asset(p["system"])
emitter = _load_asset(p["emitter_asset"])
if system is None or emitter is None:
return {"ok": False, "error": "system or emitter_asset not found"}
name = p.get("name", "")
handle_name = _lib().add_emitter_to_system(system, emitter, name)
if not handle_name:
return {"ok": False, "error": "AddEmitterToSystem returned empty"}
unreal.EditorAssetLibrary.save_asset(p["system"])
return {"ok": True, "handle_name": handle_name}
def remove_emitter(p):
system = _load_asset(p["system"])
if system is None:
return {"ok": False, "error": "system not found"}
ok = _lib().remove_emitter_from_system(system, p["emitter_name"])
if ok:
unreal.EditorAssetLibrary.save_asset(p["system"])
return {"ok": bool(ok)}
def list_emitters(p):
system = _load_asset(p["system"])
if system is None:
return {"ok": False, "error": "system not found"}
names = list(_lib().get_emitter_handle_names(system))
return {"ok": True, "emitters": names}
def list_modules(p):
system = _load_asset(p["system"])
if system is None:
return {"ok": False, "error": "system not found"}
modules = list(_lib().list_modules(system, p.get("emitter_name", ""), p["group"]))
return {"ok": True, "modules": modules}
def add_module(p):
system = _load_asset(p["system"])
script = _load_asset(p["module_script"])
if system is None or script is None:
return {"ok": False, "error": "system or module_script not found"}
name = _lib().add_module_to_stack(
system,
p.get("emitter_name", ""),
p["group"],
script,
int(p.get("index", -1)),
)
if not name:
return {"ok": False, "error": "AddModuleToStack returned empty (wrong group or script not a module?)"}
unreal.EditorAssetLibrary.save_asset(p["system"])
return {"ok": True, "module_name": name}
def remove_module(p):
system = _load_asset(p["system"])
if system is None:
return {"ok": False, "error": "system not found"}
ok = _lib().remove_module_from_stack(
system,
p.get("emitter_name", ""),
p["group"],
p["module_name"],
)
if ok:
unreal.EditorAssetLibrary.save_asset(p["system"])
return {"ok": bool(ok)}
def add_renderer(p):
system = _load_asset(p["system"])
if system is None:
return {"ok": False, "error": "system not found"}
cls = _load_class(p["renderer_class"])
if cls is None:
return {"ok": False, "error": f"renderer class {p['renderer_class']!r} not found"}
result = _lib().add_renderer_to_emitter(system, p["emitter_name"], cls)
if not result:
return {"ok": False, "error": "AddRendererToEmitter returned empty"}
unreal.EditorAssetLibrary.save_asset(p["system"])
return {"ok": True, "renderer": result}
def remove_renderer(p):
system = _load_asset(p["system"])
if system is None:
return {"ok": False, "error": "system not found"}
cls = _load_class(p["renderer_class"])
if cls is None:
return {"ok": False, "error": f"renderer class {p['renderer_class']!r} not found"}
n = int(_lib().remove_renderers_from_emitter(system, p["emitter_name"], cls))
if n:
unreal.EditorAssetLibrary.save_asset(p["system"])
return {"ok": True, "removed": n}
def assign_to_actor(p):
"""Assign a UNiagaraSystem to the NiagaraComponent of a NiagaraActor in the
current level. Identifies the actor by its label (use `name` field).
"""
system_path = p["system"]
actor_name = p["name"]
system = _load_asset(system_path)
if system is None:
return {"ok": False, "error": f"system {system_path!r} not found"}
ess = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
target = None
for a in ess.get_all_level_actors():
if a and (a.get_actor_label() == actor_name or a.get_name() == actor_name):
target = a
break
if target is None:
return {"ok": False, "error": f"actor {actor_name!r} not found"}
comp = target.get_component_by_class(unreal.NiagaraComponent)
if comp is None:
return {"ok": False, "error": "actor has no NiagaraComponent"}
comp.set_asset(system)
comp.activate(reset=True)
return {"ok": True, "actor": actor_name, "system": system_path}
def compile_system(p):
system = _load_asset(p["system"])
if system is None:
return {"ok": False, "error": "system not found"}
ok = _lib().request_compile(system)
return {"ok": bool(ok)}
def set_module_input(p):
return {
"ok": False,
"error": (
"set_module_input not implemented yet — requires "
"FNiagaraStackFunctionInputBinder + NiagaraClipboard plumbing. "
"Workaround: open the system in editor and use UI, or extend "
"MCPNiagaraLibrary with SetModuleInput(scalar|vector|enum) wrappers."
),
}
OPS = {
"create_system": create_system,
"create_emitter_asset": create_emitter_asset,
"add_emitter": add_emitter,
"remove_emitter": remove_emitter,
"list_emitters": list_emitters,
"list_modules": list_modules,
"add_module": add_module,
"remove_module": remove_module,
"add_renderer": add_renderer,
"remove_renderer": remove_renderer,
"compile": compile_system,
"assign_to_actor": assign_to_actor,
"set_module_input": set_module_input,
}
def entrypoint(payload_json: str) -> str:
try:
p = json.loads(payload_json)
except Exception as e: # noqa: BLE001
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
op = p.get("op")
fn = OPS.get(op)
if not fn:
return _tee({"ok": False, "error": f"unknown op {op!r}", "available": list(OPS)}, p)
try:
return _tee(fn(p), p)
except Exception as e: # noqa: BLE001
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
def _tee(result: dict, p: dict) -> str:
txt = json.dumps(result)
try:
if unreal is not None:
sd = unreal.Paths.project_saved_dir() + "MCP/"
os.makedirs(sd, exist_ok=True)
rid = p.get("__rid") or "last"
with open(sd + "last.json", "w", encoding="utf-8") as f:
f.write(txt)
if rid != "last":
with open(sd + f"{rid}.json", "w", encoding="utf-8") as f:
f.write(txt)
unreal.log("[MCP-NIAGARA] " + txt[:1500])
except Exception:
pass
return txt

1237
ue_side/pcg.py Normal file

File diff suppressed because it is too large Load Diff

167
ue_side/presets.py Normal file
View File

@ -0,0 +1,167 @@
"""Preset library — typical Blueprint patterns packaged as one-line MCP calls.
Each preset is a pure function: args dict → NodeSpec graph dict (apply_graph format).
The dispatcher then runs apply_graph on it.
Add a new preset:
1. Write `def my_preset(args): return {...nodes/edges...}` below.
2. Register it in PRESETS at the bottom.
3. Call via MCP: preset_op({name: "my_preset", args: {...}}).
Ships with:
print_on_begin_play {bp_path, text?} — BeginPlay → PrintString
print_on_event {bp_path, event, text?} — any event → PrintString
toggle_visibility {bp_path, event, component} — event → ToggleVisibility on a component
damage_drops_health {bp_path, health_var} — AnyDamage → Health -= Damage, branch on <=0 → DestroyActor
"""
from __future__ import annotations
import json
import os
import traceback
try:
import unreal # type: ignore
except ImportError:
unreal = None
# --- preset implementations ---------------------------------------------------
def print_on_begin_play(args: dict) -> dict:
text = args.get("text", "BeginPlay")
return {
"bp_path": args["bp_path"],
"function": "EventGraph",
"mode": "append",
"nodes": [
{"id": "ev", "kind": "event", "target": "ReceiveBeginPlay", "position": [0, 0]},
{"id": "print", "kind": "call_function", "target": "/Script/Engine.KismetSystemLibrary:PrintString",
"position": [300, 0], "params": {"InString": text}},
],
"edges": [{"from": ["ev", "then"], "to": ["print", "execute"]}],
}
def print_on_event(args: dict) -> dict:
event = args["event"] # e.g. "ReceiveActorBeginOverlap"
text = args.get("text", event)
return {
"bp_path": args["bp_path"],
"function": "EventGraph",
"mode": "append",
"nodes": [
{"id": "ev", "kind": "event", "target": event, "position": [0, 0]},
{"id": "print", "kind": "call_function", "target": "/Script/Engine.KismetSystemLibrary:PrintString",
"position": [300, 0], "params": {"InString": text}},
],
"edges": [{"from": ["ev", "then"], "to": ["print", "execute"]}],
}
def toggle_visibility(args: dict) -> dict:
event = args.get("event", "ReceiveActorBeginOverlap")
comp = args["component"] # variable name of a SceneComponent on this BP
return {
"bp_path": args["bp_path"],
"function": "EventGraph",
"mode": "append",
"nodes": [
{"id": "ev", "kind": "event", "target": event, "position": [0, 0]},
{"id": "getc", "kind": "variable_get", "target": comp, "position": [180, 80]},
{"id": "toggle", "kind": "call_function",
"target": "/Script/Engine.SceneComponent:ToggleVisibility",
"position": [380, 0]},
],
"edges": [
{"from": ["ev", "then"], "to": ["toggle", "execute"]},
{"from": ["getc", comp], "to": ["toggle", "self"]},
],
}
def damage_drops_health(args: dict) -> dict:
"""AnyDamage → Health -= Damage; if Health<=0 → DestroyActor."""
hv = args.get("health_var", "Health")
return {
"bp_path": args["bp_path"],
"function": "EventGraph",
"mode": "append",
"nodes": [
{"id": "ev", "kind": "event", "target": "ReceiveAnyDamage", "position": [-200, 0]},
{"id": "getH", "kind": "variable_get", "target": hv, "position": [-20, 60]},
{"id": "sub", "kind": "call_function",
"target": "/Script/Engine.KismetMathLibrary:Subtract_FloatFloat",
"position": [160, 60]},
{"id": "setH", "kind": "variable_set", "target": hv, "position": [340, 0]},
{"id": "getH2", "kind": "variable_get", "target": hv, "position": [560, 80]},
{"id": "le", "kind": "call_function",
"target": "/Script/Engine.KismetMathLibrary:LessEqual_FloatFloat",
"position": [720, 60]},
{"id": "br", "kind": "branch", "position": [900, 0]},
{"id": "destroy", "kind": "call_function",
"target": "/Script/Engine.Actor:K2_DestroyActor",
"position": [1080, 0]},
],
"edges": [
{"from": ["ev", "then"], "to": ["setH", "execute"]},
{"from": ["getH", hv], "to": ["sub", "A"]},
{"from": ["ev", "Damage"], "to": ["sub", "B"]},
{"from": ["sub", "ReturnValue"], "to": ["setH", hv]},
{"from": ["setH", "then"], "to": ["br", "execute"]},
{"from": ["getH2", hv], "to": ["le", "A"]},
{"from": ["le", "ReturnValue"], "to": ["br", "Condition"]},
{"from": ["br", "then"], "to": ["destroy", "execute"]},
],
}
# --- dispatch -----------------------------------------------------------------
PRESETS = {
"print_on_begin_play": print_on_begin_play,
"print_on_event": print_on_event,
"toggle_visibility": toggle_visibility,
"damage_drops_health": damage_drops_health,
}
def entrypoint(payload_json: str) -> str:
try:
p = json.loads(payload_json)
except Exception as e: # noqa: BLE001
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
name = p.get("name")
if name == "__list__":
return _tee({"ok": True, "presets": sorted(PRESETS.keys())}, p)
fn = PRESETS.get(name)
if not fn:
return _tee({"ok": False, "error": f"unknown preset {name!r}", "available": sorted(PRESETS.keys())}, p)
try:
spec = fn(p.get("args") or {})
# Run through apply_graph
import importlib, apply_graph
importlib.reload(apply_graph)
result = apply_graph.apply(spec)
result["preset"] = name
return _tee(result, p)
except Exception as e: # noqa: BLE001
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc(), "preset": name}, p)
def _tee(result: dict, p: dict) -> str:
txt = json.dumps(result)
try:
if unreal is not None:
sd = unreal.Paths.project_saved_dir() + "MCP/"
os.makedirs(sd, exist_ok=True)
rid = p.get("__rid") or "last"
with open(sd + "last.json", "w", encoding="utf-8") as f:
f.write(txt)
if rid != "last":
with open(sd + f"{rid}.json", "w", encoding="utf-8") as f:
f.write(txt)
unreal.log("[MCP-PRESET] " + txt[:1500])
except Exception:
pass
return txt

198
ue_side/render.py Normal file
View File

@ -0,0 +1,198 @@
"""Render ops — PostProcess volumes, Render targets, Scene captures, viewport screenshots.
Ops:
spawn_post_process_volume {location?, unbound?:true, settings?:{key:value}}
set_pp_setting {actor_name, key, value} — sets PostProcessVolume.Settings entries
list_pp_volumes {}
create_render_target_2d {path, width?:512, height?:512, format?:'RGBA16f|RGBA8'}
spawn_scene_capture_2d {target_render_target_path, location?, rotation?, name?}
take_screenshot {filename?, resolution?, hdr?:false} — uses HighResShot console cmd
set_viewport_realtime {enabled:bool}
capture_thumbnail {asset_path} — refresh editor thumbnail
"""
from __future__ import annotations
import json
import os
import traceback
try:
import unreal # type: ignore
except ImportError:
unreal = None
def _ess(): return unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
def _world(): return unreal.EditorLevelLibrary.get_editor_world()
def _vec(v, d=(0, 0, 0)):
if v is None: v = d
return unreal.Vector(float(v[0]), float(v[1]), float(v[2]))
def _rot(v, d=(0, 0, 0)):
if v is None: v = d
return unreal.Rotator(float(v[0]), float(v[1]), float(v[2]))
def _find(name):
for a in _ess().get_all_level_actors():
if a and (a.get_actor_label() == name or a.get_name() == name):
return a
return None
def spawn_post_process_volume(args):
cls = unreal.load_class(None, "/Script/Engine.PostProcessVolume")
actor = _ess().spawn_actor_from_class(cls, _vec(args.get("location")), _rot(None))
if actor is None:
return {"ok": False, "error": "spawn failed"}
if args.get("unbound", True):
actor.set_editor_property("unbound", True)
settings = args.get("settings") or {}
if settings:
pp = actor.get_editor_property("settings")
for k, v in settings.items():
try:
pp.set_editor_property(k, v)
except Exception:
pass
actor.set_editor_property("settings", pp)
label = args.get("name") or "PostProcessVolume_MCP"
actor.set_actor_label(label)
return {"ok": True, "name": label, "path": actor.get_path_name()}
def set_pp_setting(args):
a = _find(args["actor_name"])
if a is None:
return {"ok": False, "error": "actor not found"}
try:
pp = a.get_editor_property("settings")
pp.set_editor_property(args["key"], args["value"])
a.set_editor_property("settings", pp)
return {"ok": True}
except Exception as e:
return {"ok": False, "error": str(e)}
def list_pp_volumes(args):
cls = unreal.load_class(None, "/Script/Engine.PostProcessVolume")
out = []
for a in _ess().get_all_level_actors():
if a and a.get_class() == cls.get_default_object().get_class():
out.append({"name": a.get_actor_label(), "unbound": bool(a.get_editor_property("unbound"))})
return {"ok": True, "volumes": out}
def create_render_target_2d(args):
folder, name = args["path"].rsplit("/", 1)
factory = unreal.CanvasRenderTarget2DFactoryNew() if hasattr(unreal, "CanvasRenderTarget2DFactoryNew") else None
asset_cls = unreal.TextureRenderTarget2D
factory = unreal.TextureRenderTargetFactoryNew() if hasattr(unreal, "TextureRenderTargetFactoryNew") else factory
if factory is None:
return {"ok": False, "error": "no RT factory exposed"}
at = unreal.AssetToolsHelpers.get_asset_tools()
asset = at.create_asset(name, folder, asset_cls, factory)
if asset is None:
return {"ok": False, "error": "create returned None"}
asset.set_editor_property("size_x", int(args.get("width") or 512))
asset.set_editor_property("size_y", int(args.get("height") or 512))
fmt = args.get("format") or "RGBA8"
fmt_enum = unreal.TextureRenderTargetFormat.RTF_RGBA16F if fmt == "RGBA16f" else unreal.TextureRenderTargetFormat.RTF_RGBA8
try:
asset.set_editor_property("render_target_format", fmt_enum)
except Exception:
pass
unreal.EditorAssetLibrary.save_asset(asset.get_path_name())
return {"ok": True, "path": asset.get_path_name().split(".")[0]}
def spawn_scene_capture_2d(args):
rt = unreal.EditorAssetLibrary.load_asset(args["target_render_target_path"])
if rt is None:
return {"ok": False, "error": "render target not found"}
cls = unreal.load_class(None, "/Script/Engine.SceneCapture2D")
actor = _ess().spawn_actor_from_class(cls, _vec(args.get("location")), _rot(args.get("rotation")))
if actor is None:
return {"ok": False, "error": "spawn failed"}
try:
comp = actor.get_component_by_class(unreal.SceneCaptureComponent2D)
comp.set_editor_property("texture_target", rt)
except Exception as e:
return {"ok": False, "error": f"set RT failed: {e}"}
label = args.get("name") or "SceneCapture2D_MCP"
actor.set_actor_label(label)
return {"ok": True, "name": label}
def take_screenshot(args):
res = args.get("resolution") or "1920x1080"
fname = args.get("filename") or ""
hdr = "1" if args.get("hdr") else ""
cmd = f"HighResShot {res} {fname} {hdr}".strip()
try:
unreal.SystemLibrary.execute_console_command(_world(), cmd)
return {"ok": True, "command": cmd, "note": "Saved to <Project>/Saved/Screenshots/<Platform>/"}
except Exception as e:
return {"ok": False, "error": str(e)}
def set_viewport_realtime(args):
try:
ues = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem)
# No direct API; use console command
cmd = "r.Realtime 1" if args.get("enabled") else "r.Realtime 0"
unreal.SystemLibrary.execute_console_command(_world(), cmd)
return {"ok": True, "command": cmd}
except Exception as e:
return {"ok": False, "error": str(e)}
def capture_thumbnail(args):
paths = unreal.Array(str)
paths.append(args["asset_path"])
try:
unreal.AssetEditorSubsystem if False else None
# No direct python wrapper; rely on AssetTools? Fall back to editor cmd
unreal.SystemLibrary.execute_console_command(_world(), f"AssetThumbnailCapture {args['asset_path']}")
return {"ok": True, "note": "issued console command"}
except Exception as e:
return {"ok": False, "error": str(e)}
OPS = {
"spawn_post_process_volume": spawn_post_process_volume,
"set_pp_setting": set_pp_setting,
"list_pp_volumes": list_pp_volumes,
"create_render_target_2d": create_render_target_2d,
"spawn_scene_capture_2d": spawn_scene_capture_2d,
"take_screenshot": take_screenshot,
"set_viewport_realtime": set_viewport_realtime,
"capture_thumbnail": capture_thumbnail,
}
def entrypoint(payload_json):
try:
p = json.loads(payload_json)
except Exception as e:
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
op = p.get("op"); fn = OPS.get(op)
if not fn:
return _tee({"ok": False, "error": f"unknown op {op!r}", "available": list(OPS)}, p)
try:
return _tee(fn(p), p)
except Exception as e:
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
def _tee(result, p):
txt = json.dumps(result)
try:
if unreal is not None:
sd = unreal.Paths.project_saved_dir() + "MCP/"
os.makedirs(sd, exist_ok=True)
rid = p.get("__rid") or "last"
with open(sd + "last.json", "w", encoding="utf-8") as f: f.write(txt)
if rid != "last":
with open(sd + f"{rid}.json", "w", encoding="utf-8") as f: f.write(txt)
unreal.log("[MCP-RENDER] " + txt[:1500])
except Exception:
pass
return txt

246
ue_side/selection.py Normal file
View File

@ -0,0 +1,246 @@
"""Selection-aware Blueprint graph ops.
Ops:
get_context {bp_path, function?}
apply_relative {bp_path, function?, placement?, gap_x?, gap_y?,
delete_selection?, nodes:[], edges:[]}
This module keeps partial graph generation out of the full-graph replace path:
LLMs can inspect the user's selected nodes, then append or replace only that
local area.
"""
from __future__ import annotations
import json
import os
import traceback
import graph_layout
try:
import unreal # type: ignore
except ImportError:
unreal = None
def _lib():
L = getattr(unreal, "MCPGraphLibrary", None)
if L is None:
raise RuntimeError("MCPGraphLibrary missing - rebuild C++ plugin")
return L
def _load_bp(path: str):
a = unreal.EditorAssetLibrary.load_asset(path)
if not isinstance(a, unreal.Blueprint):
raise RuntimeError(f"not a blueprint: {path!r}")
return a
def _graph(bp, fn: str | None):
L = _lib()
if fn in (None, "", "EventGraph"):
return L.find_event_graph(bp)
return L.find_function_graph(bp, fn)
def _selected(bp) -> dict:
raw = _lib().get_selected_nodes_json(bp)
data = json.loads(raw)
nodes = data.get("selected") or []
bbox = _bbox(nodes)
focused_graph = data.get("focused_graph")
draft_zones = _draft_zones(bp, focused_graph)
return {
"focused_graph": focused_graph,
"selected": nodes,
"selected_count": len(nodes),
"bbox": bbox,
"anchors": _anchors(bbox),
"draft_zones": draft_zones,
}
def _draft_zones(bp, function_name: str | None) -> list[dict]:
graph = _graph(bp, function_name or "EventGraph")
if graph is None:
return []
raw = _lib().dump_graph_to_json(graph)
data = json.loads(raw)
zones = []
for node in data.get("nodes") or []:
if node.get("class") != "EdGraphNode_Comment":
continue
comment = str(node.get("comment") or node.get("title") or "")
if comment.strip().lower() not in ("mcp zone", "mcp draft zone"):
continue
zone = dict(node)
zone["bbox"] = {
"min_x": float(node.get("x", 0)),
"min_y": float(node.get("y", 0)),
"max_x": float(node.get("x", 0)) + float(node.get("w", 0)),
"max_y": float(node.get("y", 0)) + float(node.get("h", 0)),
"width": float(node.get("w", 0)),
"height": float(node.get("h", 0)),
"center_x": float(node.get("x", 0)) + (float(node.get("w", 0)) / 2.0),
"center_y": float(node.get("y", 0)) + (float(node.get("h", 0)) / 2.0),
}
zones.append(zone)
return zones
def _bbox(nodes: list[dict]) -> dict | None:
if not nodes:
return None
min_x = min(float(n.get("x", 0)) for n in nodes)
min_y = min(float(n.get("y", 0)) for n in nodes)
max_x = max(float(n.get("x", 0)) + float(n.get("w", 220)) for n in nodes)
max_y = max(float(n.get("y", 0)) + float(n.get("h", 120)) for n in nodes)
return {
"min_x": min_x,
"min_y": min_y,
"max_x": max_x,
"max_y": max_y,
"width": max_x - min_x,
"height": max_y - min_y,
"center_x": min_x + ((max_x - min_x) / 2.0),
"center_y": min_y + ((max_y - min_y) / 2.0),
}
def _anchors(bbox: dict | None) -> dict:
if not bbox:
return {
"right": [0, 0],
"below": [0, 320],
"center": [0, 0],
}
return {
"right": [bbox["max_x"] + 320, bbox["min_y"]],
"below": [bbox["min_x"], bbox["max_y"] + 220],
"center": [bbox["center_x"], bbox["center_y"]],
}
def get_context(args: dict) -> dict:
bp = _load_bp(args["bp_path"])
ctx = _selected(bp)
return {"ok": True, "bp_path": args["bp_path"], **ctx}
def apply_relative(args: dict) -> dict:
import importlib
import apply_graph
importlib.reload(apply_graph)
bp = _load_bp(args["bp_path"])
ctx = _selected(bp)
function_name = args.get("function") or ctx.get("focused_graph") or "EventGraph"
target_graph = _graph(bp, function_name)
if target_graph is None:
return {"ok": False, "error": f"graph not found: {function_name!r}", "selection": ctx}
placement = args.get("placement") or "right"
gap_x = float(args.get("gap_x", 0))
gap_y = float(args.get("gap_y", 0))
zone_guid = args.get("zone_guid") or ""
zone = None
for candidate in ctx.get("draft_zones") or []:
if zone_guid and candidate.get("guid") == zone_guid:
zone = candidate
break
if zone is None and not ctx.get("selected") and ctx.get("draft_zones"):
zone = ctx["draft_zones"][-1]
if zone is not None:
bbox = zone["bbox"]
zone_anchors = {
"right": [bbox["min_x"] + 80, bbox["min_y"] + 80],
"below": [bbox["min_x"] + 80, bbox["min_y"] + 80],
"center": [bbox["center_x"], bbox["center_y"]],
}
anchor = zone_anchors.get(placement) or zone_anchors["right"]
else:
anchor = ctx["anchors"].get(placement)
if anchor is None:
anchor = ctx["anchors"]["right"]
ax, ay = float(anchor[0]) + gap_x, float(anchor[1]) + gap_y
relative_nodes = []
for spec in args.get("nodes") or []:
n = dict(spec)
x, y = n.get("position") or [0, 0]
n["position"] = [float(x) + ax, float(y) + ay]
relative_nodes.append(n)
nodes = graph_layout.normalize_specs(
relative_nodes,
args.get("edges") or [],
domain="blueprint",
) if args.get("auto_layout", True) else relative_nodes
deleted = []
if args.get("delete_selection"):
for n in ctx.get("selected") or []:
guid = n.get("guid")
if guid and _lib().delete_node(target_graph, guid):
deleted.append(guid)
graph = {
"bp_path": args["bp_path"],
"function": function_name,
"mode": "append",
"nodes": nodes,
"edges": args.get("edges") or [],
}
result = apply_graph.apply(graph)
return {
"ok": bool(result.get("ok")),
"bp_path": args["bp_path"],
"function": function_name,
"placement": placement,
"anchor": [ax, ay],
"zone_guid": zone.get("guid") if zone else None,
"deleted_selection": deleted,
"selection": ctx,
"applied": result,
}
OPS = {
"get_context": get_context,
"apply_relative": apply_relative,
}
def entrypoint(payload_json: str) -> str:
try:
p = json.loads(payload_json)
except Exception as e: # noqa: BLE001
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
fn = OPS.get(p.get("op"))
if not fn:
return _tee({"ok": False, "error": f"unknown op {p.get('op')!r}", "available": list(OPS)}, p)
try:
return _tee(fn(p), p)
except Exception as e: # noqa: BLE001
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
def _tee(result: dict, p: dict) -> str:
txt = json.dumps(result)
try:
if unreal is not None:
sd = unreal.Paths.project_saved_dir() + "MCP/"
os.makedirs(sd, exist_ok=True)
rid = p.get("__rid") or "last"
with open(sd + f"{rid}.json", "w", encoding="utf-8") as f:
f.write(txt)
with open(sd + "last.json", "w", encoding="utf-8") as f:
f.write(txt)
unreal.log("[MCP-SELECTION] " + txt[:1500])
except Exception:
pass
return txt

156
ue_side/validation.py Normal file
View File

@ -0,0 +1,156 @@
"""Asset validation, redirector cleanup, reference analysis.
Ops:
validate_assets {paths:[]} — run EditorValidatorSubsystem
validate_folder {path, recursive?}
fix_redirectors {path} — recursively resolve UObjectRedirectors under folder
get_references {asset_path} — assets THIS asset depends on
get_referencers {asset_path} — assets that REFER TO this asset
find_unused {folder, ignore_paths?:[]} — assets in folder with zero referencers
disk_size {folder} — total .uasset size on disk
list_redirectors {folder}
"""
from __future__ import annotations
import json
import os
import traceback
try:
import unreal # type: ignore
except ImportError:
unreal = None
def _ar():
return unreal.AssetRegistryHelpers.get_asset_registry()
def validate_assets(args):
paths = args.get("paths") or []
evs = unreal.get_editor_subsystem(unreal.EditorValidatorSubsystem)
if evs is None:
return {"ok": False, "error": "EditorValidatorSubsystem unavailable"}
objs = [unreal.EditorAssetLibrary.load_asset(p) for p in paths]
objs = [o for o in objs if o is not None]
results = evs.validate_assets(objs, True, True) if hasattr(evs, "validate_assets") else None
return {"ok": True, "validated_count": len(objs), "result": str(results) if results is not None else None}
def validate_folder(args):
folder = args["path"]
recursive = bool(args.get("recursive", True))
filt = unreal.ARFilter(package_paths=[folder], recursive_paths=recursive)
assets = _ar().get_assets(filt)
paths = [str(d.package_name) for d in assets]
return validate_assets({"paths": paths})
def fix_redirectors(args):
folder = args["path"]
filt = unreal.ARFilter(package_paths=[folder], recursive_paths=True, class_names=["ObjectRedirector"])
redirs = _ar().get_assets(filt)
if not redirs:
return {"ok": True, "fixed": 0}
objs = [d.get_asset() for d in redirs]
objs = [o for o in objs if o is not None]
try:
unreal.AssetToolsHelpers.get_asset_tools().fixup_referencers(objs)
return {"ok": True, "fixed": len(objs)}
except Exception as e:
return {"ok": False, "error": str(e), "found": len(redirs)}
def get_references(args):
deps = _ar().get_dependencies(unreal.Name(args["asset_path"]),
unreal.AssetRegistryDependencyOptions(include_soft_package_references=True,
include_hard_package_references=True))
return {"ok": True, "dependencies": [str(d) for d in (deps or [])]}
def get_referencers(args):
refs = _ar().get_referencers(unreal.Name(args["asset_path"]),
unreal.AssetRegistryDependencyOptions(include_soft_package_references=True,
include_hard_package_references=True))
return {"ok": True, "referencers": [str(d) for d in (refs or [])]}
def find_unused(args):
folder = args["folder"]
ignore = set(args.get("ignore_paths") or [])
filt = unreal.ARFilter(package_paths=[folder], recursive_paths=True)
out = []
for d in _ar().get_assets(filt):
pkg = str(d.package_name)
if pkg in ignore or any(pkg.startswith(p) for p in ignore):
continue
refs = _ar().get_referencers(unreal.Name(pkg),
unreal.AssetRegistryDependencyOptions(include_soft_package_references=True,
include_hard_package_references=True)) or []
if not refs:
out.append({"path": pkg, "class": str(d.asset_class)})
return {"ok": True, "count": len(out), "unused": out}
def disk_size(args):
folder = args["folder"]
# Translate /Game -> Content path
proj_content = unreal.Paths.project_content_dir()
rel = folder.replace("/Game", "").lstrip("/")
root = os.path.join(proj_content, rel)
total = 0; files = 0
for dirpath, _, fnames in os.walk(root):
for f in fnames:
if f.endswith((".uasset", ".umap")):
total += os.path.getsize(os.path.join(dirpath, f))
files += 1
return {"ok": True, "folder": folder, "bytes": total, "files": files, "mb": round(total / 1024 / 1024, 2)}
def list_redirectors(args):
folder = args["folder"]
filt = unreal.ARFilter(package_paths=[folder], recursive_paths=True, class_names=["ObjectRedirector"])
out = [str(d.package_name) for d in _ar().get_assets(filt)]
return {"ok": True, "count": len(out), "redirectors": out}
OPS = {
"validate_assets": validate_assets,
"validate_folder": validate_folder,
"fix_redirectors": fix_redirectors,
"get_references": get_references,
"get_referencers": get_referencers,
"find_unused": find_unused,
"disk_size": disk_size,
"list_redirectors": list_redirectors,
}
def entrypoint(payload_json):
try:
p = json.loads(payload_json)
except Exception as e:
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
op = p.get("op"); fn = OPS.get(op)
if not fn:
return _tee({"ok": False, "error": f"unknown op {op!r}", "available": list(OPS)}, p)
try:
return _tee(fn(p), p)
except Exception as e:
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
def _tee(result, p):
txt = json.dumps(result)
try:
if unreal is not None:
sd = unreal.Paths.project_saved_dir() + "MCP/"
os.makedirs(sd, exist_ok=True)
rid = p.get("__rid") or "last"
with open(sd + "last.json", "w", encoding="utf-8") as f: f.write(txt)
if rid != "last":
with open(sd + f"{rid}.json", "w", encoding="utf-8") as f: f.write(txt)
unreal.log("[MCP-VALIDATE] " + txt[:1500])
except Exception:
pass
return txt

180
ue_side/variables.py Normal file
View File

@ -0,0 +1,180 @@
"""Blueprint member-variable authoring ops.
Wraps the (already-compiled) C++ helpers on unreal.MCPGraphLibrary:
AddMemberVariable / SetVariableDefaultValue / SetVariableMetadata /
RenameVariable / DeleteVariable / DescribeBlueprintJson.
Reachable from JS as the `variable_op` tool, and also merged into `asset_op`'s
op-table for zero-restart availability (see assets.py).
Ops:
add {bp_path, name, type, default?, instance_editable?, expose_on_spawn?,
category?, tooltip?} — create a member variable, optionally set its
default value + metadata. type is one of
bool|int|float|double|string|name|text or a
class path / short name for an object ref.
set_default {bp_path, name, value}
set_meta {bp_path, name, instance_editable?, expose_on_spawn?, category?, tooltip?}
rename {bp_path, old, new}
delete {bp_path, name}
list {bp_path} — current member variables (name+type) as JSON.
"""
from __future__ import annotations
import json
import os
import traceback
try:
import unreal # type: ignore
except ImportError:
unreal = None
def _lib():
L = getattr(unreal, "MCPGraphLibrary", None)
if L is None:
raise RuntimeError("MCPGraphLibrary missing — rebuild C++ plugin")
return L
def _load_bp(path: str):
a = unreal.EditorAssetLibrary.load_asset(path)
if not isinstance(a, unreal.Blueprint):
raise RuntimeError(f"not a blueprint: {path!r}")
return a
def _compile_save(bp):
_lib().compile_and_save_blueprint(bp)
unreal.EditorAssetLibrary.save_loaded_asset(bp)
def _variables(bp) -> list:
"""Return [{name,type,...}] from DescribeBlueprintJson."""
try:
info = json.loads(_lib().describe_blueprint_json(bp))
return info.get("variables", [])
except Exception: # noqa: BLE001
return []
def _has_var(bp, name: str) -> bool:
return any(v.get("name") == name for v in _variables(bp))
def add(args: dict) -> dict:
bp = _load_bp(args["bp_path"])
name = args["name"]
type_spec = args.get("type") or args.get("type_spec")
if not name or not type_spec:
return {"ok": False, "error": "add requires {name, type}"}
if _has_var(bp, name):
return {"ok": False, "error": f"variable {name!r} already exists", "exists": True}
ok = bool(_lib().add_member_variable(bp, name, type_spec))
if not ok:
return {"ok": False, "error": f"add_member_variable failed (bad type {type_spec!r}?)"}
# Optional default value + metadata in the same transaction.
if "default" in args and args["default"] is not None:
_lib().set_variable_default_value(bp, name, str(args["default"]))
if any(k in args for k in ("instance_editable", "expose_on_spawn", "category", "tooltip")):
_lib().set_variable_metadata(
bp, name,
bool(args.get("instance_editable", False)),
bool(args.get("expose_on_spawn", False)),
str(args.get("category", "")),
str(args.get("tooltip", "")),
)
_compile_save(bp)
return {"ok": True, "name": name, "type": type_spec}
def set_default(args: dict) -> dict:
bp = _load_bp(args["bp_path"])
ok = bool(_lib().set_variable_default_value(bp, args["name"], str(args.get("value", ""))))
if ok: _compile_save(bp)
return {"ok": ok, "name": args["name"]}
def set_meta(args: dict) -> dict:
bp = _load_bp(args["bp_path"])
ok = bool(_lib().set_variable_metadata(
bp, args["name"],
bool(args.get("instance_editable", False)),
bool(args.get("expose_on_spawn", False)),
str(args.get("category", "")),
str(args.get("tooltip", "")),
))
if ok: _compile_save(bp)
return {"ok": ok, "name": args["name"]}
def rename(args: dict) -> dict:
bp = _load_bp(args["bp_path"])
ok = bool(_lib().rename_variable(bp, args["old"], args["new"]))
if ok: _compile_save(bp)
return {"ok": ok, "old": args["old"], "new": args["new"]}
def delete(args: dict) -> dict:
bp = _load_bp(args["bp_path"])
ok = bool(_lib().delete_variable(bp, args["name"]))
if ok: _compile_save(bp)
return {"ok": ok, "name": args["name"]}
def list_vars(args: dict) -> dict:
bp = _load_bp(args["bp_path"])
return {"ok": True, "variables": _variables(bp)}
# op-table — also merged into assets.OPS for zero-restart availability.
VAR_OPS = {
"add_variable": add,
"add": add,
"set_var_default": set_default,
"set_default": set_default,
"set_var_meta": set_meta,
"set_meta": set_meta,
"rename_variable": rename,
"rename": rename,
"delete_variable": delete,
"delete": delete,
"list_variables": list_vars,
"list": list_vars,
}
def entrypoint(payload_json: str) -> str:
try:
p = json.loads(payload_json)
except Exception as e: # noqa: BLE001
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
op = p.get("op")
fn = VAR_OPS.get(op)
if not fn:
return _tee({"ok": False, "error": f"unknown op {op!r}", "available": sorted(VAR_OPS)}, p)
try:
return _tee(fn(p), p)
except Exception as e: # noqa: BLE001
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
def _tee(result: dict, p: dict) -> str:
txt = json.dumps(result)
try:
if unreal is not None:
sd = unreal.Paths.project_saved_dir() + "MCP/"
os.makedirs(sd, exist_ok=True)
rid = p.get("__rid") or "last"
with open(sd + "last.json", "w", encoding="utf-8") as f:
f.write(txt)
if rid != "last":
with open(sd + f"{rid}.json", "w", encoding="utf-8") as f:
f.write(txt)
unreal.log("[MCP-VARS] " + txt[:1500])
except Exception:
pass
return txt

430
ue_side/voxel_graph.py Normal file
View File

@ -0,0 +1,430 @@
"""Voxel Graph MCP ops.
This worker mirrors the small, dependable graph-editing surface used by the
Blueprint worker, but targets Voxel Plugin terminal graphs.
Ops:
list_terminals {asset_path}
read_graph {asset_path, terminal?}
get_selection {asset_path, terminal?}
get_context {asset_path, terminal?}
list_node_types {asset_path?, filter?, limit?}
spawn_node {asset_path, terminal?, node, x,y, compile?}
create_graph {path}
add_property {asset_path, terminal?, kind, name, type?, category?}
list_properties {asset_path, terminal?, kind}
spawn_property {asset_path, terminal?, kind, ref, x,y, declaration?, compile?}
spawn_call {asset_path, terminal?, function, x,y, compile?}
compile {asset_path, terminal?}
spawn_comment {asset_path, terminal?, x,y,width,height,text?, color?}
clear_zones {asset_path, terminal?}
delete_node {asset_path, terminal?, guid}
break_link {asset_path, terminal?, from:[guid,pin], to:[guid,pin]}
break_all {asset_path, terminal?, guid}
connect_pins {asset_path, terminal?, from:[guid,pin], to:[guid,pin]}
set_pin_default {asset_path, terminal?, guid,pin,value}
move_nodes {asset_path, terminal?, moves:[{guid,x,y}]}
resize_comment {asset_path, terminal?, guid,width,height}
set_comment {asset_path, terminal?, guid,text,bubble?}
open_asset {asset_path}
"""
from __future__ import annotations
import json
import os
import traceback
try:
import unreal # type: ignore
except ImportError:
unreal = None
def _graph_lib():
lib = getattr(unreal, "MCPGraphLibrary", None)
if lib is None:
raise RuntimeError("MCPGraphLibrary missing - rebuild C++ plugin")
return lib
def _voxel_lib():
lib = getattr(unreal, "MCPVoxelGraphLibrary", None)
if lib is None:
raise RuntimeError("MCPVoxelGraphLibrary missing - rebuild C++ plugin")
return lib
def _load_asset(path: str):
asset = unreal.EditorAssetLibrary.load_asset(path)
if asset is None:
raise RuntimeError(f"asset not found: {path!r}")
if not bool(_voxel_lib().is_voxel_graph_asset(asset)):
raise RuntimeError(f"asset is not a UVoxelGraph: {path!r} ({asset.get_class().get_name()})")
return asset
def _terminal(args: dict) -> str:
return str(args.get("terminal") or "main")
def _ed_graph(asset, args: dict):
graph = _voxel_lib().find_terminal_ed_graph(asset, _terminal(args))
if graph is None:
raise RuntimeError(f"terminal graph not found: {_terminal(args)!r}")
return graph
def _save(asset):
try:
unreal.EditorAssetLibrary.save_loaded_asset(asset)
except Exception:
pass
def _changed(asset, graph, compile_graph: bool = True):
_voxel_lib().notify_graph_changed(graph, bool(compile_graph))
_save(asset)
def list_terminals(args: dict) -> dict:
asset = _load_asset(args["asset_path"])
return json.loads(_voxel_lib().list_terminal_graphs_json(asset))
def read_graph(args: dict) -> dict:
asset = _load_asset(args["asset_path"])
return json.loads(_voxel_lib().dump_terminal_graph_json(asset, _terminal(args)))
def get_selection(args: dict) -> dict:
asset = _load_asset(args["asset_path"])
return json.loads(_voxel_lib().get_selected_nodes_json(asset, _terminal(args)))
def _bbox(nodes: list[dict]) -> dict | None:
if not nodes:
return None
min_x = min(float(node.get("x", 0)) for node in nodes)
min_y = min(float(node.get("y", 0)) for node in nodes)
max_x = max(float(node.get("x", 0)) + float(node.get("w", 220)) for node in nodes)
max_y = max(float(node.get("y", 0)) + float(node.get("h", 120)) for node in nodes)
return {
"min_x": min_x,
"min_y": min_y,
"max_x": max_x,
"max_y": max_y,
"width": max_x - min_x,
"height": max_y - min_y,
"center_x": min_x + ((max_x - min_x) / 2.0),
"center_y": min_y + ((max_y - min_y) / 2.0),
}
def _zones(nodes: list[dict]) -> list[dict]:
out = []
for node in nodes:
if node.get("class") != "EdGraphNode_Comment":
continue
comment = str(node.get("comment") or node.get("title") or "").strip().lower()
if comment not in ("mcp zone", "mcp draft zone"):
continue
zone = dict(node)
zone["bbox"] = _bbox([node])
out.append(zone)
return out
def get_context(args: dict) -> dict:
graph_data = read_graph(args)
selection_data = get_selection(args)
selected = selection_data.get("selected") or []
return {
"ok": bool(graph_data.get("ok")),
"asset": graph_data.get("asset"),
"terminal": graph_data.get("terminal"),
"nodes": graph_data.get("nodes") or [],
"selected": selected,
"selected_count": len(selected),
"bbox": _bbox(selected),
"zones": _zones(graph_data.get("nodes") or []),
"selection_error": selection_data.get("error"),
}
def list_node_types(args: dict) -> dict:
# asset_path is optional here: the catalogue is global, but we keep the
# signature consistent with the rest of the worker when one is supplied.
flt = str(args.get("filter") or "")
limit = int(args.get("limit") or 0)
return json.loads(_voxel_lib().list_node_types_json(flt, limit))
def spawn_node(args: dict) -> dict:
asset = _load_asset(args["asset_path"])
graph = _ed_graph(asset, args)
node_id = str(args.get("node") or args.get("node_id") or "").strip()
if not node_id:
raise RuntimeError("spawn_node requires 'node' (id / display name)")
guid = _voxel_lib().spawn_struct_node(
graph,
node_id,
unreal.Vector2D(float(args.get("x", 0)), float(args.get("y", 0))),
)
ok = bool(guid)
if ok:
_changed(asset, graph, compile_graph=bool(args.get("compile", False)))
return {"ok": ok, "guid": guid, "node": node_id, "terminal": _terminal(args)}
def create_graph(args: dict) -> dict:
path = str(args["path"]).rstrip("/")
pkg_dir, _, name = path.rpartition("/")
if not name or not pkg_dir:
raise RuntimeError(f"bad path: {path!r} (expected e.g. /Game/MyGraph)")
tools = unreal.AssetToolsHelpers.get_asset_tools()
asset = tools.create_asset(name, pkg_dir, unreal.VoxelGraph, None)
if asset is None:
raise RuntimeError(f"failed to create voxel graph at {path}")
unreal.EditorAssetLibrary.save_loaded_asset(asset)
return {"ok": True, "asset": asset.get_path_name()}
def add_property(args: dict) -> dict:
asset = _load_asset(args["asset_path"])
result = json.loads(_voxel_lib().add_graph_property_json(
asset,
_terminal(args),
str(args.get("kind") or "parameter"),
str(args.get("name") or ""),
str(args.get("type") or "float"),
str(args.get("category") or ""),
))
if result.get("ok"):
_save(asset)
return result
def list_properties(args: dict) -> dict:
asset = _load_asset(args["asset_path"])
return json.loads(_voxel_lib().list_graph_properties_json(
asset, _terminal(args), str(args.get("kind") or "parameter")))
def spawn_property(args: dict) -> dict:
asset = _load_asset(args["asset_path"])
graph = _ed_graph(asset, args)
ref = str(args.get("ref") or args.get("name") or "").strip()
if not ref:
raise RuntimeError("spawn_property requires 'ref' (guid or name)")
guid = _voxel_lib().spawn_property_node(
graph,
str(args.get("kind") or "parameter"),
ref,
unreal.Vector2D(float(args.get("x", 0)), float(args.get("y", 0))),
bool(args.get("declaration", False)),
)
ok = bool(guid)
if ok:
_changed(asset, graph, compile_graph=bool(args.get("compile", False)))
return {"ok": ok, "guid": guid, "kind": str(args.get("kind") or "parameter"), "terminal": _terminal(args)}
def spawn_call(args: dict) -> dict:
asset = _load_asset(args["asset_path"])
graph = _ed_graph(asset, args)
func = str(args.get("function") or "").strip()
if not func:
raise RuntimeError("spawn_call requires 'function' (terminal guid or name)")
guid = _voxel_lib().spawn_call_function_node(
graph,
func,
unreal.Vector2D(float(args.get("x", 0)), float(args.get("y", 0))),
)
ok = bool(guid)
if ok:
_changed(asset, graph, compile_graph=bool(args.get("compile", False)))
return {"ok": ok, "guid": guid, "function": func, "terminal": _terminal(args)}
def compile_graph(args: dict) -> dict:
asset = _load_asset(args["asset_path"])
graph = _ed_graph(asset, args)
_changed(asset, graph, compile_graph=True)
return {"ok": True, "terminal": _terminal(args)}
def spawn_comment(args: dict) -> dict:
asset = _load_asset(args["asset_path"])
graph = _ed_graph(asset, args)
color = args.get("color") or [0.05, 0.45, 0.9, 0.35]
while len(color) < 4:
color.append(1.0)
guid = _voxel_lib().spawn_comment_node(
graph,
unreal.Vector2D(float(args.get("x", 0)), float(args.get("y", 0))),
unreal.Vector2D(float(args.get("width", 420)), float(args.get("height", 260))),
str(args.get("text") or "MCP Zone"),
unreal.LinearColor(float(color[0]), float(color[1]), float(color[2]), float(color[3])),
)
ok = bool(guid)
if ok:
_changed(asset, graph, compile_graph=False)
return {"ok": ok, "guid": guid, "terminal": _terminal(args)}
def clear_zones(args: dict) -> dict:
asset = _load_asset(args["asset_path"])
graph = _ed_graph(asset, args)
data = read_graph(args)
deleted = []
for zone in _zones(data.get("nodes") or []):
guid = zone.get("guid")
if guid and _graph_lib().delete_node(graph, guid):
deleted.append(guid)
if deleted:
_changed(asset, graph, compile_graph=False)
return {"ok": True, "deleted": deleted, "deleted_count": len(deleted)}
def delete_node(args: dict) -> dict:
asset = _load_asset(args["asset_path"])
graph = _ed_graph(asset, args)
ok = bool(_graph_lib().delete_node(graph, args["guid"]))
if ok:
_changed(asset, graph)
return {"ok": ok, "guid": args["guid"]}
def break_link(args: dict) -> dict:
asset = _load_asset(args["asset_path"])
graph = _ed_graph(asset, args)
from_guid, from_pin = args["from"]
to_guid, to_pin = args["to"]
ok = bool(_graph_lib().break_link(graph, from_guid, from_pin, to_guid, to_pin))
if ok:
_changed(asset, graph)
return {"ok": ok}
def break_all(args: dict) -> dict:
asset = _load_asset(args["asset_path"])
graph = _ed_graph(asset, args)
ok = bool(_graph_lib().break_all_node_links(graph, args["guid"]))
if ok:
_changed(asset, graph)
return {"ok": ok}
def connect_pins(args: dict) -> dict:
asset = _load_asset(args["asset_path"])
graph = _ed_graph(asset, args)
from_guid, from_pin = args["from"]
to_guid, to_pin = args["to"]
ok = bool(_graph_lib().connect_pins(graph, from_guid, from_pin, to_guid, to_pin))
if ok:
_changed(asset, graph)
return {"ok": ok}
def set_pin_default(args: dict) -> dict:
asset = _load_asset(args["asset_path"])
graph = _ed_graph(asset, args)
ok = bool(_graph_lib().set_pin_default(graph, args["guid"], args["pin"], str(args.get("value", ""))))
if ok:
_changed(asset, graph)
return {"ok": ok}
def move_nodes(args: dict) -> dict:
asset = _load_asset(args["asset_path"])
graph = _ed_graph(asset, args)
moves = args.get("moves") or []
ids = [m["guid"] for m in moves]
positions = [unreal.Vector2D(float(m["x"]), float(m["y"])) for m in moves]
count = int(_graph_lib().move_nodes(graph, ids, positions))
if count:
_changed(asset, graph, compile_graph=False)
return {"ok": count > 0, "moved": count}
def resize_comment(args: dict) -> dict:
asset = _load_asset(args["asset_path"])
graph = _ed_graph(asset, args)
ok = bool(_graph_lib().resize_comment_node(graph, args["guid"], unreal.Vector2D(float(args["width"]), float(args["height"]))))
if ok:
_changed(asset, graph, compile_graph=False)
return {"ok": ok}
def set_comment(args: dict) -> dict:
asset = _load_asset(args["asset_path"])
graph = _ed_graph(asset, args)
ok = bool(_graph_lib().set_node_comment(graph, args["guid"], str(args.get("text", "")), bool(args.get("bubble", False))))
if ok:
_changed(asset, graph, compile_graph=False)
return {"ok": ok}
def open_asset(args: dict) -> dict:
asset = _load_asset(args["asset_path"])
return {"ok": bool(_voxel_lib().open_voxel_graph_asset(asset))}
OPS = {
"list_terminals": list_terminals,
"read_graph": read_graph,
"get_selection": get_selection,
"get_context": get_context,
"list_node_types": list_node_types,
"spawn_node": spawn_node,
"create_graph": create_graph,
"add_property": add_property,
"list_properties": list_properties,
"spawn_property": spawn_property,
"spawn_call": spawn_call,
"compile": compile_graph,
"spawn_comment": spawn_comment,
"clear_zones": clear_zones,
"delete_node": delete_node,
"break_link": break_link,
"break_all": break_all,
"connect_pins": connect_pins,
"set_pin_default": set_pin_default,
"move_nodes": move_nodes,
"resize_comment": resize_comment,
"set_comment": set_comment,
"open_asset": open_asset,
}
def entrypoint(payload_json: str) -> str:
try:
payload = json.loads(payload_json)
except Exception as exc: # noqa: BLE001
return _tee({"ok": False, "error": f"bad json: {exc}"}, {})
fn = OPS.get(payload.get("op"))
if not fn:
return _tee({"ok": False, "error": f"unknown op {payload.get('op')!r}", "available": list(OPS)}, payload)
try:
return _tee(fn(payload), payload)
except Exception as exc: # noqa: BLE001
return _tee({"ok": False, "error": str(exc), "traceback": traceback.format_exc()}, payload)
def _tee(result: dict, payload: dict) -> str:
text = json.dumps(result)
try:
if unreal is not None:
saved_dir = unreal.Paths.project_saved_dir() + "MCP/"
os.makedirs(saved_dir, exist_ok=True)
rid = payload.get("__rid") or "last"
with open(saved_dir + f"{rid}.json", "w", encoding="utf-8") as handle:
handle.write(text)
with open(saved_dir + "last.json", "w", encoding="utf-8") as handle:
handle.write(text)
unreal.log("[MCP-VOXEL-GRAPH] " + text[:1500])
except Exception:
pass
return text

451
ue_side/widget_tree.py Normal file
View File

@ -0,0 +1,451 @@
"""UMG Designer / WidgetTree ops.
Ops:
describe {bp_path}
get_props {bp_path, widget}
get_slot_props {bp_path, widget}
add_widget {bp_path, class, name?, parent?, index?, is_variable?, properties?, canvas_slot?}
remove_widget {bp_path, widget}
rename_widget {bp_path, widget, new_name}
move_widget {bp_path, widget, parent?, index?}
set_root {bp_path, widget}
set_is_variable {bp_path, widget, is_variable}
set_property {bp_path, widget, property, value}
set_properties {bp_path, widget, properties}
set_slot_property {bp_path, widget, property, value}
set_canvas_slot {bp_path, widget, position?, size?, anchors_min?, anchors_max?, alignment?, auto_size?, z_order?}
set_named_slot {bp_path, host, slot, content}
clear_named_slot {bp_path, host, slot}
set_desired_focus {bp_path, widget}
apply_tree {bp_path, clear_existing?, widgets:[...], desired_focus?}
compile_save {bp_path}
"""
from __future__ import annotations
import json
import os
import traceback
try:
import unreal # type: ignore
except ImportError:
unreal = None
def _lib():
L = getattr(unreal, "MCPGraphLibrary", None)
if L is None:
raise RuntimeError("MCPGraphLibrary missing - rebuild C++ plugin")
return L
def _load_widget_bp(path: str):
asset = unreal.EditorAssetLibrary.load_asset(path)
if not isinstance(asset, unreal.WidgetBlueprint):
raise RuntimeError(f"not a WidgetBlueprint: {path!r}")
return asset
def _load_widget_class(path_or_name: str):
if not path_or_name:
raise RuntimeError("widget class required")
if "/" in path_or_name:
cls = unreal.load_class(None, path_or_name)
if cls:
return cls
for prefix in (
"/Script/UMG.",
"/Script/CommonUI.",
"/Script/AdvancedWidgets.",
"/Script/Engine.",
):
cls = unreal.load_class(None, prefix + path_or_name)
if cls:
return cls
raise RuntimeError(f"widget class not found: {path_or_name!r}")
def _vec2(value, default):
if value is None:
value = default
return unreal.Vector2D(float(value[0]), float(value[1]))
def _json(raw: str) -> dict:
return json.loads(raw or "{}")
def _save(bp):
unreal.BlueprintEditorLibrary.compile_blueprint(bp)
unreal.EditorAssetLibrary.save_loaded_asset(bp)
def describe(args: dict) -> dict:
bp = _load_widget_bp(args["bp_path"])
return {"ok": True, **_json(_lib().describe_widget_tree_json(bp))}
def get_props(args: dict) -> dict:
bp = _load_widget_bp(args["bp_path"])
return {"ok": True, "widget": args["widget"], **_json(_lib().get_widget_properties_json(bp, args["widget"]))}
def get_slot_props(args: dict) -> dict:
bp = _load_widget_bp(args["bp_path"])
return {"ok": True, "widget": args["widget"], **_json(_lib().get_widget_slot_properties_json(bp, args["widget"]))}
def _set_props(bp, widget_name: str, props: dict) -> list[str]:
warnings = []
for key, value in (props or {}).items():
ok = bool(_lib().set_widget_property(bp, widget_name, str(key), str(value)))
if not ok:
warnings.append(f"set_widget_property failed: {widget_name}.{key}")
return warnings
def _set_canvas_slot(bp, widget_name: str, slot: dict) -> bool:
return bool(_lib().set_canvas_slot_layout(
bp,
widget_name,
_vec2(slot.get("position"), (0, 0)),
_vec2(slot.get("size"), (100, 40)),
_vec2(slot.get("anchors_min"), (0, 0)),
_vec2(slot.get("anchors_max"), (0, 0)),
_vec2(slot.get("alignment"), (0, 0)),
bool(slot.get("auto_size", False)),
int(slot.get("z_order", 0)),
))
def add_widget(args: dict) -> dict:
bp = _load_widget_bp(args["bp_path"])
cls = _load_widget_class(args["class"])
name = _lib().add_widget_to_tree(
bp,
cls,
args.get("name") or "",
args.get("parent") or "",
int(args.get("index", -1)),
bool(args.get("is_variable", True)),
)
if not name:
return {"ok": False, "error": "add_widget_to_tree failed"}
warnings = _set_props(bp, name, args.get("properties") or {})
if args.get("canvas_slot") and not _set_canvas_slot(bp, name, args["canvas_slot"]):
warnings.append(f"set_canvas_slot failed: {name}")
_save(bp)
return {"ok": True, "bp_path": args["bp_path"], "widget": name, "warnings": warnings}
def remove_widget(args: dict) -> dict:
bp = _load_widget_bp(args["bp_path"])
ok = bool(_lib().remove_widget_from_tree(bp, args["widget"]))
_save(bp)
return {"ok": ok, "widget": args["widget"]}
def rename_widget(args: dict) -> dict:
bp = _load_widget_bp(args["bp_path"])
ok = bool(_lib().rename_widget_in_tree(bp, args["widget"], args["new_name"]))
_save(bp)
return {"ok": ok, "widget": args["widget"], "new_name": args["new_name"]}
def move_widget(args: dict) -> dict:
bp = _load_widget_bp(args["bp_path"])
ok = bool(_lib().move_widget_in_tree(bp, args["widget"], args.get("parent") or "", int(args.get("index", -1))))
_save(bp)
return {"ok": ok, "widget": args["widget"], "parent": args.get("parent") or ""}
def set_root(args: dict) -> dict:
bp = _load_widget_bp(args["bp_path"])
ok = bool(_lib().set_root_widget(bp, args["widget"]))
_save(bp)
return {"ok": ok, "root": args["widget"]}
def set_is_variable(args: dict) -> dict:
bp = _load_widget_bp(args["bp_path"])
ok = bool(_lib().set_widget_is_variable(bp, args["widget"], bool(args.get("is_variable", True))))
_save(bp)
return {"ok": ok, "widget": args["widget"], "is_variable": bool(args.get("is_variable", True))}
def set_property(args: dict) -> dict:
bp = _load_widget_bp(args["bp_path"])
ok = bool(_lib().set_widget_property(bp, args["widget"], args["property"], str(args.get("value", ""))))
_save(bp)
return {"ok": ok, "widget": args["widget"], "property": args["property"]}
def set_properties(args: dict) -> dict:
bp = _load_widget_bp(args["bp_path"])
warnings = _set_props(bp, args["widget"], args.get("properties") or {})
_save(bp)
return {"ok": not warnings, "widget": args["widget"], "warnings": warnings}
def set_slot_property(args: dict) -> dict:
bp = _load_widget_bp(args["bp_path"])
ok = bool(_lib().set_widget_slot_property(bp, args["widget"], args["property"], str(args.get("value", ""))))
_save(bp)
return {"ok": ok, "widget": args["widget"], "property": args["property"]}
def set_canvas_slot(args: dict) -> dict:
bp = _load_widget_bp(args["bp_path"])
ok = _set_canvas_slot(bp, args["widget"], args)
_save(bp)
return {"ok": ok, "widget": args["widget"]}
def set_named_slot(args: dict) -> dict:
bp = _load_widget_bp(args["bp_path"])
ok = bool(_lib().set_named_slot_content(bp, args["host"], args["slot"], args["content"]))
_save(bp)
return {"ok": ok, "host": args["host"], "slot": args["slot"], "content": args["content"]}
def clear_named_slot(args: dict) -> dict:
bp = _load_widget_bp(args["bp_path"])
ok = bool(_lib().clear_named_slot_content(bp, args["host"], args["slot"]))
_save(bp)
return {"ok": ok, "host": args["host"], "slot": args["slot"]}
def set_desired_focus(args: dict) -> dict:
bp = _load_widget_bp(args["bp_path"])
ok = bool(_lib().set_desired_focus_widget(bp, args["widget"]))
_save(bp)
return {"ok": ok, "widget": args["widget"]}
def apply_tree(args: dict) -> dict:
bp = _load_widget_bp(args["bp_path"])
warnings = []
created = []
if args.get("clear_existing"):
tree = _json(_lib().describe_widget_tree_json(bp))
root = tree.get("root")
if root:
_lib().remove_widget_from_tree(bp, root)
for spec in args.get("widgets") or []:
cls = _load_widget_class(spec["class"])
name = _lib().add_widget_to_tree(
bp,
cls,
spec.get("name") or "",
spec.get("parent") or "",
int(spec.get("index", -1)),
bool(spec.get("is_variable", True)),
)
if not name:
warnings.append(f"add failed: {spec.get('name') or spec.get('class')}")
continue
created.append(name)
warnings.extend(_set_props(bp, name, spec.get("properties") or {}))
if spec.get("canvas_slot") and not _set_canvas_slot(bp, name, spec["canvas_slot"]):
warnings.append(f"set_canvas_slot failed: {name}")
if args.get("desired_focus"):
if not _lib().set_desired_focus_widget(bp, args["desired_focus"]):
warnings.append(f"set_desired_focus failed: {args['desired_focus']}")
_save(bp)
return {"ok": not warnings, "created": created, "warnings": warnings, "tree": _json(_lib().describe_widget_tree_json(bp))}
def compile_save(args: dict) -> dict:
bp = _load_widget_bp(args["bp_path"])
_save(bp)
return {"ok": True, "bp_path": args["bp_path"]}
def create_animation(args: dict) -> dict:
"""Create a UMG WidgetAnimation that keys one float property on a widget.
{bp_path, name, widget, property?='RenderOpacity', times?, values?}
times/values are parallel lists (seconds / value). Omit both for a default
blink pulse (1.0 -> 0.12 -> 1.0 over 0.9s). Loop is set at PlayAnimation time
(NumLoopsToPlay=0), not stored on the animation.
"""
bp = _load_widget_bp(args["bp_path"])
name = args["name"]
widget = args["widget"]
prop = args.get("property") or "RenderOpacity"
times = args.get("times")
values = args.get("values")
if isinstance(times, str):
times = json.loads(times)
if isinstance(values, str):
values = json.loads(values)
if not times or not values:
times = [0.0, 0.45, 0.9]
values = [1.0, 0.12, 1.0]
if len(times) != len(values):
return {"ok": False, "error": "times and values must be the same length"}
times = [float(t) for t in times]
values = [float(v) for v in values]
ok = bool(_lib().create_widget_float_animation(bp, name, widget, prop, times, values))
if ok:
_save(bp)
return {"ok": ok, "name": name, "widget": widget, "property": prop,
"keys": list(zip(times, values))}
def screenshot(args: dict) -> dict:
"""Render a WidgetBlueprint to a PNG file. Vision loop for AI iteration.
Strategy stack (first that works wins):
1) unreal.WidgetRenderer -> RenderTarget -> ExportRenderTarget (clean, widget-only)
2) Open the WBP in the UMG editor and use HighResShot (captures editor chrome)
Args:
bp_path: /Game/.../WBP_Foo
size: [w, h] default [1920, 1080]
out_dir: project-relative folder under Saved/ default 'MCP/widgets'
Returns:
{ ok, png_path, file_url, strategy }
"""
bp_path = args["bp_path"]
bp = _load_widget_bp(bp_path)
if bp is None:
return {"ok": False, "error": "widget bp not found"}
size = args.get("size") or [1920, 1080]
w, h = int(size[0]), int(size[1])
proj_saved = unreal.Paths.project_saved_dir()
out_subdir = (args.get("out_dir") or "MCP/widgets").strip("/")
out_dir = proj_saved + out_subdir + "/"
os.makedirs(out_dir, exist_ok=True)
safe_name = bp_path.rsplit("/", 1)[-1]
png_name = f"{safe_name}.png"
# ---- Strategy 0: native C++ helper (best — pure widget, no editor chrome) ----
if hasattr(unreal, "MCPWidgetRenderLibrary"):
try:
widget_class = bp.generated_class()
ok, full_path, err = unreal.MCPWidgetRenderLibrary.render_widget_to_png(
widget_class, out_dir.rstrip("/").replace("/", os.sep), safe_name, w, h
)
if ok:
return {"ok": True, "strategy": "MCPWidgetRenderLibrary",
"png_path": full_path,
"file_url": "file:///" + full_path.replace("\\", "/"),
"size": [w, h]}
cpp_err = err or "unknown C++ error"
except Exception as e:
cpp_err = str(e)
else:
cpp_err = "MCPWidgetRenderLibrary not exposed — Live-Coding compile the plugin"
# ---- Strategy 1: WidgetRenderer (if exposed) ----
if hasattr(unreal, "WidgetRenderer"):
try:
world = unreal.EditorLevelLibrary.get_editor_world() if hasattr(unreal, "EditorLevelLibrary") else None
widget_class = bp.generated_class()
instance = unreal.create_widget(world, widget_class) if world else None
if instance is None:
raise RuntimeError("create_widget returned None")
# Transient render target
rt_factory = getattr(unreal, "TextureRenderTargetFactoryNew", None)
if rt_factory is None:
raise RuntimeError("TextureRenderTargetFactoryNew unavailable")
rt = unreal.AssetToolsHelpers.get_asset_tools().create_asset(
f"RT_MCP_{safe_name}", "/Game/_MCPTransient", unreal.TextureRenderTarget2D, rt_factory()
)
rt.set_editor_property("size_x", w)
rt.set_editor_property("size_y", h)
rt.set_editor_property("render_target_format", unreal.TextureRenderTargetFormat.RTF_RGBA8)
renderer = unreal.WidgetRenderer()
renderer.draw_widget(instance, rt, 0.016)
unreal.RenderingLibrary.export_render_target(world, rt, out_dir, png_name)
# Cleanup transient RT (best effort)
try:
unreal.EditorAssetLibrary.delete_asset(rt.get_path_name())
except Exception:
pass
full = out_dir + png_name
return {"ok": True, "strategy": "WidgetRenderer", "png_path": full,
"file_url": "file:///" + full.replace("\\", "/"),
"size": [w, h]}
except Exception as e:
renderer_err = str(e)
else:
renderer_err = "WidgetRenderer not exposed in this UE build"
# ---- Strategy 2: open in UMG editor + HighResShot ----
try:
aes = unreal.get_editor_subsystem(unreal.AssetEditorSubsystem)
aes.open_editor_for_assets([bp])
# Force a screenshot of the editor window
world = unreal.EditorLevelLibrary.get_editor_world()
cmd = f"HighResShot {w}x{h} {png_name}"
unreal.SystemLibrary.execute_console_command(world, cmd)
# UE saves under Saved/Screenshots/Windows/<png_name>
guessed = proj_saved + f"Screenshots/Windows/{png_name}"
return {"ok": True, "strategy": "HighResShot_editor_chrome",
"warning": f"widget-only render failed: {renderer_err}. Editor chrome included.",
"png_path": guessed,
"file_url": "file:///" + guessed.replace("\\", "/"),
"size": [w, h]}
except Exception as e:
return {"ok": False, "error": f"all strategies failed; cpp={cpp_err}; renderer={renderer_err}; screenshot={e}"}
OPS = {
"describe": describe,
"get_props": get_props,
"get_slot_props": get_slot_props,
"add_widget": add_widget,
"remove_widget": remove_widget,
"rename_widget": rename_widget,
"move_widget": move_widget,
"set_root": set_root,
"set_is_variable": set_is_variable,
"set_property": set_property,
"set_properties": set_properties,
"set_slot_property": set_slot_property,
"set_canvas_slot": set_canvas_slot,
"set_named_slot": set_named_slot,
"clear_named_slot": clear_named_slot,
"set_desired_focus": set_desired_focus,
"apply_tree": apply_tree,
"compile_save": compile_save,
"create_animation": create_animation,
"screenshot": screenshot,
}
def entrypoint(payload_json: str) -> str:
try:
p = json.loads(payload_json)
except Exception as e: # noqa: BLE001
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
fn = OPS.get(p.get("op"))
if not fn:
return _tee({"ok": False, "error": f"unknown op {p.get('op')!r}", "available": list(OPS)}, p)
try:
return _tee(fn(p), p)
except Exception as e: # noqa: BLE001
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
def _tee(result: dict, p: dict) -> str:
txt = json.dumps(result)
try:
if unreal is not None:
sd = unreal.Paths.project_saved_dir() + "MCP/"
os.makedirs(sd, exist_ok=True)
rid = p.get("__rid") or "last"
with open(sd + "last.json", "w", encoding="utf-8") as f:
f.write(txt)
if rid != "last":
with open(sd + f"{rid}.json", "w", encoding="utf-8") as f:
f.write(txt)
unreal.log("[MCP-WIDGET] " + txt[:1500])
except Exception:
pass
return txt