diff --git a/.gitignore b/.gitignore index 715b38f..b9c6f7a 100644 --- a/.gitignore +++ b/.gitignore @@ -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 + diff --git a/FUTURE_MCP_TOOLS.md b/FUTURE_MCP_TOOLS.md new file mode 100644 index 0000000..f3d58fa --- /dev/null +++ b/FUTURE_MCP_TOOLS.md @@ -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`, 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**. diff --git a/README.md b/README.md index 892c72d..d82782e 100644 --- a/README.md +++ b/README.md @@ -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`: + + ``` + /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": ["/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` + (``, `Editor`, `Saved/Logs/.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. diff --git a/Source/UEBlueprintMCPEditor/Private/MCPCaptureLibrary.cpp b/Source/UEBlueprintMCPEditor/Private/MCPCaptureLibrary.cpp new file mode 100644 index 0000000..f5af68f --- /dev/null +++ b/Source/UEBlueprintMCPEditor/Private/MCPCaptureLibrary.cpp @@ -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(FName("ImageWrapper")); + TSharedPtr 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& 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(W), + static_cast(H), + ThumbnailTools::EThumbnailTextureFlushMode::AlwaysFlush, + nullptr, + &Thumbnail); + + const TArray& 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(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(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 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(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; +} diff --git a/Source/UEBlueprintMCPEditor/Private/MCPGraphLibrary.cpp b/Source/UEBlueprintMCPEditor/Private/MCPGraphLibrary.cpp new file mode 100644 index 0000000..0215bba --- /dev/null +++ b/Source/UEBlueprintMCPEditor/Private/MCPGraphLibrary.cpp @@ -0,0 +1,2372 @@ +#include "MCPGraphLibrary.h" + +#include "Engine/Blueprint.h" +#include "Engine/SimpleConstructionScript.h" +#include "Engine/SCS_Node.h" +#include "EdGraph/EdGraph.h" +#include "EdGraph/EdGraphNode.h" +#include "EdGraph/EdGraphPin.h" +#include "EdGraphSchema_K2.h" +#include "EdGraphSchema_K2_Actions.h" + +// K2Nodes +#include "K2Node_CallFunction.h" +#include "K2Node_Event.h" +#include "K2Node_CustomEvent.h" +#include "K2Node_ComponentBoundEvent.h" +#include "K2Node_IfThenElse.h" +#include "K2Node_ExecutionSequence.h" +#include "K2Node_VariableGet.h" +#include "K2Node_VariableSet.h" +#include "K2Node_Self.h" +#include "K2Node_DynamicCast.h" +#include "K2Node_SwitchInteger.h" +#include "K2Node_SwitchString.h" +#include "K2Node_SwitchEnum.h" +#include "K2Node_MakeArray.h" +#include "K2Node_MakeStruct.h" +#include "K2Node_BreakStruct.h" +#include "K2Node_SetFieldsInStruct.h" +#include "K2Node_MacroInstance.h" +#include "K2Node_SpawnActorFromClass.h" +#include "K2Node_FormatText.h" +#include "K2Node_GetSubsystem.h" +#include "K2Node_LoadAsset.h" +#include "K2Node_AddDelegate.h" +#include "K2Node_RemoveDelegate.h" +#include "K2Node_CallDelegate.h" +#include "K2Node_AssignDelegate.h" + +#include "K2Node_Knot.h" +#include "K2Node_GetArrayItem.h" +#include "K2Node_EnumLiteral.h" +#include "K2Node_GetClassDefaults.h" +#include "K2Node_FunctionResult.h" +#include "K2Node_FunctionEntry.h" +#include "K2Node_FunctionTerminator.h" +#include "K2Node_Switch.h" +#include "EdGraphNode_Comment.h" + +#include "Subsystems/AssetEditorSubsystem.h" +#include "Editor.h" +#include "BlueprintEditor.h" +#include "AssetRegistry/AssetRegistryModule.h" +#include "AssetRegistry/IAssetRegistry.h" + +#include "Kismet2/BlueprintEditorUtils.h" +#include "Kismet2/KismetEditorUtilities.h" +#include "WidgetBlueprint.h" +#include "WidgetBlueprintEditorUtils.h" +#include "Blueprint/WidgetTree.h" +#include "Kismet/KismetSystemLibrary.h" +#include "KismetCompilerMisc.h" +#include "KismetCompiler.h" +#include "Components/ActorComponent.h" +#include "Components/BoxComponent.h" +#include "Components/CanvasPanelSlot.h" +#include "Components/NamedSlotInterface.h" +#include "Components/PanelSlot.h" +#include "Components/PanelWidget.h" +#include "Components/SceneComponent.h" +#include "Components/Widget.h" +#include "Layout/Margin.h" +#include "UObject/UnrealType.h" +#include "Logging/TokenizedMessage.h" +#include "Misc/UObjectToken.h" +#include "Serialization/JsonSerializer.h" +#include "Serialization/JsonWriter.h" +#include "Dom/JsonObject.h" +#include "Dom/JsonValue.h" + +// UMG WidgetAnimation authoring +#include "Animation/WidgetAnimation.h" +#include "MovieScene.h" +#include "Tracks/MovieSceneFloatTrack.h" +#include "Sections/MovieSceneFloatSection.h" +#include "Channels/MovieSceneFloatChannel.h" +#include "Channels/MovieSceneChannelProxy.h" + +// --------------------------------------------------------------------------- +// Generic spawn helper +// --------------------------------------------------------------------------- +template +TNode* UMCPGraphLibrary::PlaceNode(UEdGraph* Graph, FVector2D Position) +{ + if (!Graph) return nullptr; + TNode* Node = NewObject(Graph); + Node->CreateNewGuid(); + Node->NodePosX = static_cast(Position.X); + Node->NodePosY = static_cast(Position.Y); + Graph->AddNode(Node, /*bUserAction*/ true, /*bSelectNewNode*/ false); + Node->PostPlacedNewNode(); + Node->AllocateDefaultPins(); + return Node; +} + +static void MarkBPDirty(UBlueprint* BP) +{ + if (BP) FBlueprintEditorUtils::MarkBlueprintAsModified(BP); +} + +static FString MCPWidgetJsonStringify(const TSharedRef& Object) +{ + FString Out; + const TSharedRef> Writer = TJsonWriterFactory<>::Create(&Out); + FJsonSerializer::Serialize(Object, Writer); + return Out; +} + +static UWidgetTree* MCPGetWidgetTree(UWidgetBlueprint* BP) +{ + return BP ? BP->WidgetTree : nullptr; +} + +static UWidget* MCPFindWidget(UWidgetBlueprint* BP, FName WidgetName) +{ + UWidgetTree* Tree = MCPGetWidgetTree(BP); + if (!Tree) return nullptr; + if (WidgetName.IsNone() || WidgetName == FName(TEXT("Root")) || WidgetName == FName(TEXT("root"))) + { + return Tree->RootWidget; + } + return Tree->FindWidget(WidgetName); +} + +static void MarkWidgetBPDirty(UWidgetBlueprint* BP, bool bStructurallyModified = true) +{ + if (!BP) return; + BP->Modify(); + if (BP->WidgetTree) + { + BP->WidgetTree->SetFlags(RF_Transactional); + BP->WidgetTree->Modify(); + } + if (bStructurallyModified) + { + FBlueprintEditorUtils::MarkBlueprintAsStructurallyModified(BP); + } + else + { + FBlueprintEditorUtils::MarkBlueprintAsModified(BP); + } +} + +static bool SetObjectPropertyFromString(UObject* Object, FName PropertyName, const FString& Value) +{ + if (!Object || PropertyName.IsNone()) return false; + FProperty* Property = FindFProperty(Object->GetClass(), PropertyName); + if (!Property) return false; + Object->SetFlags(RF_Transactional); + Object->Modify(); + void* PropertyValue = Property->ContainerPtrToValuePtr(Object); + const TCHAR* Buffer = *Value; + if (!Property->ImportText_Direct(Buffer, PropertyValue, Object, PPF_None)) + { + return false; + } +#if WITH_EDITOR + FPropertyChangedEvent Event(Property); + Object->PostEditChangeProperty(Event); +#endif + return true; +} + +static TSharedRef ExportObjectPropertiesJson(UObject* Object) +{ + TSharedRef Root = MakeShared(); + if (!Object) + { + Root->SetBoolField(TEXT("ok"), false); + Root->SetStringField(TEXT("error"), TEXT("null object")); + return Root; + } + + Root->SetBoolField(TEXT("ok"), true); + Root->SetStringField(TEXT("class"), Object->GetClass()->GetPathName()); + TMap Props; + FWidgetBlueprintEditorUtils::ExportPropertiesToText(Object, Props); + TSharedRef PropJson = MakeShared(); + for (const TPair& Pair : Props) + { + PropJson->SetStringField(Pair.Key.ToString(), Pair.Value); + } + Root->SetObjectField(TEXT("properties"), PropJson); + return Root; +} + +static TSharedRef WidgetSlotJson(UWidget* Widget) +{ + TSharedRef SlotJson = MakeShared(); + if (!Widget || !Widget->Slot) + { + SlotJson->SetBoolField(TEXT("has_slot"), false); + return SlotJson; + } + + UPanelSlot* Slot = Widget->Slot; + SlotJson->SetBoolField(TEXT("has_slot"), true); + SlotJson->SetStringField(TEXT("class"), Slot->GetClass()->GetPathName()); + if (Slot->Parent) + { + SlotJson->SetStringField(TEXT("parent"), Slot->Parent->GetFName().ToString()); + } + + if (UCanvasPanelSlot* CanvasSlot = Cast(Slot)) + { + const FVector2D Pos = CanvasSlot->GetPosition(); + const FVector2D Size = CanvasSlot->GetSize(); + const FVector2D Align = CanvasSlot->GetAlignment(); + const FAnchors Anchors = CanvasSlot->GetAnchors(); + SlotJson->SetArrayField(TEXT("position"), { + MakeShared(Pos.X), + MakeShared(Pos.Y) + }); + SlotJson->SetArrayField(TEXT("size"), { + MakeShared(Size.X), + MakeShared(Size.Y) + }); + SlotJson->SetArrayField(TEXT("alignment"), { + MakeShared(Align.X), + MakeShared(Align.Y) + }); + SlotJson->SetArrayField(TEXT("anchors_min"), { + MakeShared(Anchors.Minimum.X), + MakeShared(Anchors.Minimum.Y) + }); + SlotJson->SetArrayField(TEXT("anchors_max"), { + MakeShared(Anchors.Maximum.X), + MakeShared(Anchors.Maximum.Y) + }); + SlotJson->SetBoolField(TEXT("auto_size"), CanvasSlot->GetAutoSize()); + SlotJson->SetNumberField(TEXT("z_order"), CanvasSlot->GetZOrder()); + } + return SlotJson; +} + +static TSharedRef WidgetToJson(UWidget* Widget) +{ + TSharedRef Json = MakeShared(); + if (!Widget) + { + Json->SetBoolField(TEXT("ok"), false); + return Json; + } + + Json->SetStringField(TEXT("name"), Widget->GetFName().ToString()); + Json->SetStringField(TEXT("class"), Widget->GetClass()->GetPathName()); + Json->SetStringField(TEXT("label"), Widget->GetLabelText().ToString()); + Json->SetBoolField(TEXT("is_variable"), Widget->bIsVariable); + Json->SetObjectField(TEXT("slot"), WidgetSlotJson(Widget)); + + TArray> Children; + if (UPanelWidget* Panel = Cast(Widget)) + { + const int32 ChildCount = Panel->GetChildrenCount(); + for (int32 Index = 0; Index < ChildCount; ++Index) + { + if (UWidget* Child = Panel->GetChildAt(Index)) + { + TSharedRef ChildRef = MakeShared(); + ChildRef->SetStringField(TEXT("name"), Child->GetFName().ToString()); + ChildRef->SetStringField(TEXT("class"), Child->GetClass()->GetPathName()); + ChildRef->SetNumberField(TEXT("index"), Index); + Children.Add(MakeShared(ChildRef)); + } + } + } + Json->SetArrayField(TEXT("children"), Children); + + TArray SlotNames; + if (INamedSlotInterface* NamedSlots = Cast(Widget)) + { + NamedSlots->GetSlotNames(SlotNames); + TArray> NamedSlotValues; + for (FName SlotName : SlotNames) + { + TSharedRef Slot = MakeShared(); + Slot->SetStringField(TEXT("slot"), SlotName.ToString()); + if (UWidget* Content = NamedSlots->GetContentForSlot(SlotName)) + { + Slot->SetStringField(TEXT("content"), Content->GetFName().ToString()); + } + NamedSlotValues.Add(MakeShared(Slot)); + } + Json->SetArrayField(TEXT("named_slots"), NamedSlotValues); + } + return Json; +} + +static bool AddExistingWidgetToParent(UWidgetBlueprint* BP, UWidget* Widget, UWidget* Parent, int32 Index) +{ + if (!BP || !Widget) return false; + UWidgetTree* Tree = BP->WidgetTree; + if (!Tree) return false; + + Widget->SetFlags(RF_Transactional); + Widget->Modify(); + if (!Parent) + { + if (Tree->RootWidget && Tree->RootWidget != Widget) + { + return false; + } + Tree->RootWidget = Widget; + return true; + } + + if (UPanelWidget* Panel = Cast(Parent)) + { + Panel->SetFlags(RF_Transactional); + Panel->Modify(); + if (!Panel->CanAddMoreChildren()) + { + return false; + } + if (Index >= 0 && Index <= Panel->GetChildrenCount()) + { + return Panel->InsertChildAt(Index, Widget) != nullptr; + } + return Panel->AddChild(Widget) != nullptr; + } + return false; +} + +// --------------------------------------------------------------------------- +// Graph lookup +// --------------------------------------------------------------------------- +UEdGraph* UMCPGraphLibrary::FindEventGraph(UBlueprint* Blueprint) +{ + if (!Blueprint) return nullptr; + for (UEdGraph* G : Blueprint->UbergraphPages) { if (G) return G; } + return nullptr; +} + +UEdGraph* UMCPGraphLibrary::FindFunctionGraph(UBlueprint* Blueprint, FName FunctionName) +{ + if (!Blueprint) return nullptr; + for (UEdGraph* G : Blueprint->FunctionGraphs) { if (G && G->GetFName() == FunctionName) return G; } + for (UEdGraph* G : Blueprint->UbergraphPages) { if (G && G->GetFName() == FunctionName) return G; } + for (UEdGraph* G : Blueprint->MacroGraphs) { if (G && G->GetFName() == FunctionName) return G; } + return nullptr; +} + +TArray UMCPGraphLibrary::ListNodeIds(UEdGraph* Graph) +{ + TArray Out; + if (!Graph) return Out; + for (UEdGraphNode* N : Graph->Nodes) { if (N) Out.Add(N->NodeGuid.ToString()); } + return Out; +} + +TArray UMCPGraphLibrary::ListPinsOnNode(UEdGraph* Graph, const FString& NodeId) +{ + TArray Out; + UEdGraphNode* N = FindNodeByGuid(Graph, NodeId); + if (!N) return Out; + for (UEdGraphPin* P : N->Pins) + { + if (!P) continue; + const TCHAR* Dir = (P->Direction == EGPD_Input) ? TEXT("in") : TEXT("out"); + Out.Add(FString::Printf(TEXT("%s|%s|%s"), *P->PinName.ToString(), Dir, *P->PinType.PinCategory.ToString())); + } + return Out; +} + +int32 UMCPGraphLibrary::ClearGraph(UEdGraph* Graph) +{ + if (!Graph) return 0; + TArray Snapshot = Graph->Nodes; + int32 Deleted = 0; + for (UEdGraphNode* N : Snapshot) + { + if (!N) continue; + Graph->RemoveNode(N); + ++Deleted; + } + if (UBlueprint* BP = FBlueprintEditorUtils::FindBlueprintForGraph(Graph)) + { + MarkBPDirty(BP); + } + return Deleted; +} + +bool UMCPGraphLibrary::DeleteNode(UEdGraph* Graph, const FString& NodeId) +{ + if (!Graph) return false; + UEdGraphNode* N = FindNodeByGuid(Graph, NodeId); + if (!N) return false; + Graph->RemoveNode(N); + if (UBlueprint* BP = FBlueprintEditorUtils::FindBlueprintForGraph(Graph)) MarkBPDirty(BP); + return true; +} + +// --------------------------------------------------------------------------- +// Spawners — basic +// --------------------------------------------------------------------------- +FString UMCPGraphLibrary::SpawnCallFunctionNode(UBlueprint* Blueprint, UEdGraph* Graph, UClass* FunctionOwnerClass, FName FunctionName, FVector2D Position) +{ + if (!Blueprint || !Graph || !FunctionOwnerClass) return FString(); + UK2Node_CallFunction* Node = NewObject(Graph); + Node->CreateNewGuid(); + Node->FunctionReference.SetExternalMember(FunctionName, FunctionOwnerClass); + Node->NodePosX = static_cast(Position.X); + Node->NodePosY = static_cast(Position.Y); + Graph->AddNode(Node, true, false); + Node->PostPlacedNewNode(); + Node->AllocateDefaultPins(); + MarkBPDirty(Blueprint); + return Node->NodeGuid.ToString(); +} + +FString UMCPGraphLibrary::SpawnEventNode(UBlueprint* Blueprint, UEdGraph* Graph, UClass* OwnerClass, FName EventName, FVector2D Position) +{ + if (!Blueprint || !Graph) return FString(); + UClass* EffectiveOwner = OwnerClass; + if (!EffectiveOwner) EffectiveOwner = Blueprint->ParentClass; + if (!EffectiveOwner) return FString(); + + UK2Node_Event* Node = NewObject(Graph); + Node->CreateNewGuid(); + Node->EventReference.SetExternalMember(EventName, EffectiveOwner); + Node->bOverrideFunction = true; + Node->NodePosX = static_cast(Position.X); + Node->NodePosY = static_cast(Position.Y); + Graph->AddNode(Node, true, false); + Node->PostPlacedNewNode(); + Node->AllocateDefaultPins(); + MarkBPDirty(Blueprint); + return Node->NodeGuid.ToString(); +} + +FString UMCPGraphLibrary::SpawnComponentBoundEvent(UBlueprint* Blueprint, UEdGraph* Graph, FName ComponentName, FName DelegateName, FVector2D Position) +{ + if (!Blueprint || !Graph) return FString(); + UClass* OwnerClass = Blueprint->GeneratedClass ? Blueprint->GeneratedClass : Blueprint->ParentClass; + if (!OwnerClass) return FString(); + FObjectProperty* CompProp = FindFProperty(OwnerClass, ComponentName); + if (!CompProp || !CompProp->PropertyClass) return FString(); + FMulticastDelegateProperty* DelegateProp = FindFProperty(CompProp->PropertyClass, DelegateName); + if (!DelegateProp) return FString(); + + UK2Node_ComponentBoundEvent* Node = NewObject(Graph); + Node->CreateNewGuid(); + Node->InitializeComponentBoundEventParams(CompProp, DelegateProp); + Node->NodePosX = static_cast(Position.X); + Node->NodePosY = static_cast(Position.Y); + Graph->AddNode(Node, true, false); + Node->PostPlacedNewNode(); + Node->AllocateDefaultPins(); + MarkBPDirty(Blueprint); + return Node->NodeGuid.ToString(); +} + +FString UMCPGraphLibrary::SpawnCustomEventNode(UBlueprint* Blueprint, UEdGraph* Graph, FName EventName, FVector2D Position) +{ + UK2Node_CustomEvent* Node = PlaceNode(Graph, Position); + if (!Node) return FString(); + Node->CustomFunctionName = EventName; + Node->ReconstructNode(); + MarkBPDirty(Blueprint); + return Node->NodeGuid.ToString(); +} + +bool UMCPGraphLibrary::SetCustomEventNetworkFlags(UBlueprint* Blueprint, UEdGraph* Graph, const FString& NodeIdOrEventName, FName Mode, bool bReliable) +{ + if (!Graph) return false; + + UK2Node_CustomEvent* EventNode = Cast(FindNodeByGuid(Graph, NodeIdOrEventName)); + if (!EventNode) + { + for (UEdGraphNode* Node : Graph->Nodes) + { + UK2Node_CustomEvent* Candidate = Cast(Node); + if (Candidate && Candidate->CustomFunctionName.ToString() == NodeIdOrEventName) + { + EventNode = Candidate; + break; + } + } + } + if (!EventNode) return false; + + uint32 Flags = EventNode->FunctionFlags; + Flags &= ~(FUNC_Net | FUNC_NetServer | FUNC_NetClient | FUNC_NetMulticast | FUNC_NetReliable); + + const FString ModeText = Mode.ToString().ToLower(); + if (ModeText == TEXT("server") || ModeText == TEXT("run_on_server")) + { + Flags |= FUNC_Net | FUNC_NetServer; + } + else if (ModeText == TEXT("client") || ModeText == TEXT("run_on_owning_client")) + { + Flags |= FUNC_Net | FUNC_NetClient; + } + else if (ModeText == TEXT("multicast") || ModeText == TEXT("netmulticast")) + { + Flags |= FUNC_Net | FUNC_NetMulticast; + } + else if (ModeText == TEXT("none") || ModeText == TEXT("local")) + { + // Keep flags cleared. + } + else + { + return false; + } + + if (bReliable && (Flags & FUNC_Net)) + { + Flags |= FUNC_NetReliable; + } + + EventNode->Modify(); + EventNode->FunctionFlags = Flags; + EventNode->ReconstructNode(); + MarkBPDirty(Blueprint ? Blueprint : FBlueprintEditorUtils::FindBlueprintForGraph(Graph)); + return true; +} + +FString UMCPGraphLibrary::SpawnBranchNode(UBlueprint* Blueprint, UEdGraph* Graph, FVector2D Position) +{ + UK2Node_IfThenElse* Node = PlaceNode(Graph, Position); + if (!Node) return FString(); + MarkBPDirty(Blueprint); + return Node->NodeGuid.ToString(); +} + +FString UMCPGraphLibrary::SpawnSequenceNode(UBlueprint* Blueprint, UEdGraph* Graph, FVector2D Position, int32 OutputCount) +{ + UK2Node_ExecutionSequence* Node = PlaceNode(Graph, Position); + if (!Node) return FString(); + const int32 Extra = FMath::Max(0, OutputCount - 2); + for (int32 i = 0; i < Extra; ++i) Node->AddInputPin(); + MarkBPDirty(Blueprint); + return Node->NodeGuid.ToString(); +} + +FString UMCPGraphLibrary::SpawnVariableGetNode(UBlueprint* Blueprint, UEdGraph* Graph, FName VariableName, FVector2D Position) +{ + if (!Blueprint || !Graph) return FString(); + UK2Node_VariableGet* Node = NewObject(Graph); + Node->CreateNewGuid(); + Node->VariableReference.SetSelfMember(VariableName); + Node->NodePosX = static_cast(Position.X); + Node->NodePosY = static_cast(Position.Y); + Graph->AddNode(Node, true, false); + Node->PostPlacedNewNode(); + Node->AllocateDefaultPins(); + MarkBPDirty(Blueprint); + return Node->NodeGuid.ToString(); +} + +FString UMCPGraphLibrary::SpawnVariableSetNode(UBlueprint* Blueprint, UEdGraph* Graph, FName VariableName, FVector2D Position) +{ + if (!Blueprint || !Graph) return FString(); + UK2Node_VariableSet* Node = NewObject(Graph); + Node->CreateNewGuid(); + Node->VariableReference.SetSelfMember(VariableName); + Node->NodePosX = static_cast(Position.X); + Node->NodePosY = static_cast(Position.Y); + Graph->AddNode(Node, true, false); + Node->PostPlacedNewNode(); + Node->AllocateDefaultPins(); + MarkBPDirty(Blueprint); + return Node->NodeGuid.ToString(); +} + +FString UMCPGraphLibrary::SpawnSelfNode(UBlueprint* Blueprint, UEdGraph* Graph, FVector2D Position) +{ + UK2Node_Self* Node = PlaceNode(Graph, Position); + if (!Node) return FString(); + MarkBPDirty(Blueprint); + return Node->NodeGuid.ToString(); +} + +FString UMCPGraphLibrary::SpawnCastNode(UBlueprint* Blueprint, UEdGraph* Graph, UClass* TargetClass, FVector2D Position) +{ + if (!Blueprint || !Graph || !TargetClass) return FString(); + UK2Node_DynamicCast* Node = NewObject(Graph); + Node->CreateNewGuid(); + Node->TargetType = TargetClass; + Node->NodePosX = static_cast(Position.X); + Node->NodePosY = static_cast(Position.Y); + Graph->AddNode(Node, true, false); + Node->PostPlacedNewNode(); + Node->AllocateDefaultPins(); + MarkBPDirty(Blueprint); + return Node->NodeGuid.ToString(); +} + +// --------------------------------------------------------------------------- +// Spawners — flow / collections +// --------------------------------------------------------------------------- +FString UMCPGraphLibrary::SpawnSwitchOnIntNode(UBlueprint* Blueprint, UEdGraph* Graph, FVector2D Position) +{ + UK2Node_SwitchInteger* Node = PlaceNode(Graph, Position); + if (!Node) return FString(); + MarkBPDirty(Blueprint); + return Node->NodeGuid.ToString(); +} + +FString UMCPGraphLibrary::SpawnSwitchOnStringNode(UBlueprint* Blueprint, UEdGraph* Graph, FVector2D Position) +{ + UK2Node_SwitchString* Node = PlaceNode(Graph, Position); + if (!Node) return FString(); + MarkBPDirty(Blueprint); + return Node->NodeGuid.ToString(); +} + +FString UMCPGraphLibrary::SpawnSwitchOnEnumNode(UBlueprint* Blueprint, UEdGraph* Graph, UEnum* EnumType, FVector2D Position) +{ + if (!Blueprint || !Graph || !EnumType) return FString(); + UK2Node_SwitchEnum* Node = NewObject(Graph); + Node->CreateNewGuid(); + Node->SetEnum(EnumType); + Node->NodePosX = static_cast(Position.X); + Node->NodePosY = static_cast(Position.Y); + Graph->AddNode(Node, true, false); + Node->PostPlacedNewNode(); + Node->AllocateDefaultPins(); + MarkBPDirty(Blueprint); + return Node->NodeGuid.ToString(); +} + +FString UMCPGraphLibrary::SpawnMakeArrayNode(UBlueprint* Blueprint, UEdGraph* Graph, FVector2D Position) +{ + UK2Node_MakeArray* Node = PlaceNode(Graph, Position); + if (!Node) return FString(); + MarkBPDirty(Blueprint); + return Node->NodeGuid.ToString(); +} + +FString UMCPGraphLibrary::SpawnMakeStructNode(UBlueprint* Blueprint, UEdGraph* Graph, UScriptStruct* StructType, FVector2D Position) +{ + if (!Blueprint || !Graph || !StructType) return FString(); + UK2Node_MakeStruct* Node = NewObject(Graph); + Node->CreateNewGuid(); + Node->StructType = StructType; + Node->NodePosX = static_cast(Position.X); + Node->NodePosY = static_cast(Position.Y); + Graph->AddNode(Node, true, false); + Node->PostPlacedNewNode(); + Node->AllocateDefaultPins(); + MarkBPDirty(Blueprint); + return Node->NodeGuid.ToString(); +} + +FString UMCPGraphLibrary::SpawnBreakStructNode(UBlueprint* Blueprint, UEdGraph* Graph, UScriptStruct* StructType, FVector2D Position) +{ + if (!Blueprint || !Graph || !StructType) return FString(); + UK2Node_BreakStruct* Node = NewObject(Graph); + Node->CreateNewGuid(); + Node->StructType = StructType; + Node->NodePosX = static_cast(Position.X); + Node->NodePosY = static_cast(Position.Y); + Graph->AddNode(Node, true, false); + Node->PostPlacedNewNode(); + Node->AllocateDefaultPins(); + MarkBPDirty(Blueprint); + return Node->NodeGuid.ToString(); +} + +FString UMCPGraphLibrary::SpawnSetFieldsInStructNode(UBlueprint* Blueprint, UEdGraph* Graph, UScriptStruct* StructType, FVector2D Position) +{ + if (!Blueprint || !Graph || !StructType) return FString(); + UK2Node_SetFieldsInStruct* Node = NewObject(Graph); + Node->CreateNewGuid(); + Node->StructType = StructType; + Node->NodePosX = static_cast(Position.X); + Node->NodePosY = static_cast(Position.Y); + Graph->AddNode(Node, true, false); + Node->PostPlacedNewNode(); + Node->AllocateDefaultPins(); + MarkBPDirty(Blueprint); + return Node->NodeGuid.ToString(); +} + +FString UMCPGraphLibrary::SpawnStandardMacroNode(UBlueprint* Blueprint, UEdGraph* Graph, FName MacroName, FVector2D Position) +{ + if (!Blueprint || !Graph) return FString(); + + // Load StandardMacros library (engine asset). + UBlueprint* MacroLib = LoadObject(nullptr, TEXT("/Engine/EditorBlueprintResources/StandardMacros.StandardMacros")); + if (!MacroLib) return FString(); + + UEdGraph* MacroGraph = nullptr; + for (UEdGraph* G : MacroLib->MacroGraphs) + { + if (G && G->GetFName() == MacroName) { MacroGraph = G; break; } + } + if (!MacroGraph) return FString(); + + UK2Node_MacroInstance* Node = NewObject(Graph); + Node->CreateNewGuid(); + Node->SetMacroGraph(MacroGraph); + Node->NodePosX = static_cast(Position.X); + Node->NodePosY = static_cast(Position.Y); + Graph->AddNode(Node, true, false); + Node->PostPlacedNewNode(); + Node->AllocateDefaultPins(); + MarkBPDirty(Blueprint); + return Node->NodeGuid.ToString(); +} + +FString UMCPGraphLibrary::SpawnSpawnActorFromClassNode(UBlueprint* Blueprint, UEdGraph* Graph, FVector2D Position) +{ + UK2Node_SpawnActorFromClass* Node = PlaceNode(Graph, Position); + if (!Node) return FString(); + MarkBPDirty(Blueprint); + return Node->NodeGuid.ToString(); +} + +FString UMCPGraphLibrary::SpawnFormatTextNode(UBlueprint* Blueprint, UEdGraph* Graph, FVector2D Position) +{ + UK2Node_FormatText* Node = PlaceNode(Graph, Position); + if (!Node) return FString(); + MarkBPDirty(Blueprint); + return Node->NodeGuid.ToString(); +} + +FString UMCPGraphLibrary::SpawnGetSubsystemNode(UBlueprint* Blueprint, UEdGraph* Graph, UClass* SubsystemClass, FVector2D Position) +{ + if (!Blueprint || !Graph || !SubsystemClass) return FString(); + UK2Node_GetSubsystem* Node = NewObject(Graph); + Node->CreateNewGuid(); + Node->Initialize(SubsystemClass); + Node->NodePosX = static_cast(Position.X); + Node->NodePosY = static_cast(Position.Y); + Graph->AddNode(Node, true, false); + Node->PostPlacedNewNode(); + Node->AllocateDefaultPins(); + MarkBPDirty(Blueprint); + return Node->NodeGuid.ToString(); +} + +FString UMCPGraphLibrary::SpawnLoadAssetNode(UBlueprint* Blueprint, UEdGraph* Graph, FVector2D Position) +{ + UK2Node_LoadAsset* Node = PlaceNode(Graph, Position); + if (!Node) return FString(); + MarkBPDirty(Blueprint); + return Node->NodeGuid.ToString(); +} + +// --------------------------------------------------------------------------- +// Delegates +// --------------------------------------------------------------------------- +static bool ResolveComponentDelegate(UBlueprint* BP, FName ComponentName, FName DelegateName, FObjectProperty*& OutCompProp, FMulticastDelegateProperty*& OutDelegateProp) +{ + OutCompProp = nullptr; OutDelegateProp = nullptr; + if (!BP) return false; + UClass* OwnerClass = BP->GeneratedClass ? BP->GeneratedClass : BP->ParentClass; + if (!OwnerClass) return false; + OutCompProp = FindFProperty(OwnerClass, ComponentName); + if (!OutCompProp || !OutCompProp->PropertyClass) return false; + OutDelegateProp = FindFProperty(OutCompProp->PropertyClass, DelegateName); + return OutDelegateProp != nullptr; +} + +FString UMCPGraphLibrary::SpawnBindDelegateNode(UBlueprint* Blueprint, UEdGraph* Graph, FName ComponentName, FName DelegateName, FVector2D Position) +{ + FObjectProperty* CompProp = nullptr; FMulticastDelegateProperty* DelegateProp = nullptr; + if (!ResolveComponentDelegate(Blueprint, ComponentName, DelegateName, CompProp, DelegateProp)) return FString(); + + UK2Node_AddDelegate* Node = NewObject(Graph); + Node->CreateNewGuid(); + Node->SetFromProperty(DelegateProp, /*bSelfContext*/ false, DelegateProp->GetOwnerClass()); + Node->NodePosX = static_cast(Position.X); + Node->NodePosY = static_cast(Position.Y); + Graph->AddNode(Node, true, false); + Node->PostPlacedNewNode(); + Node->AllocateDefaultPins(); + MarkBPDirty(Blueprint); + return Node->NodeGuid.ToString(); +} + +FString UMCPGraphLibrary::SpawnUnbindDelegateNode(UBlueprint* Blueprint, UEdGraph* Graph, FName ComponentName, FName DelegateName, FVector2D Position) +{ + FObjectProperty* CompProp = nullptr; FMulticastDelegateProperty* DelegateProp = nullptr; + if (!ResolveComponentDelegate(Blueprint, ComponentName, DelegateName, CompProp, DelegateProp)) return FString(); + + UK2Node_RemoveDelegate* Node = NewObject(Graph); + Node->CreateNewGuid(); + Node->SetFromProperty(DelegateProp, false, DelegateProp->GetOwnerClass()); + Node->NodePosX = static_cast(Position.X); + Node->NodePosY = static_cast(Position.Y); + Graph->AddNode(Node, true, false); + Node->PostPlacedNewNode(); + Node->AllocateDefaultPins(); + MarkBPDirty(Blueprint); + return Node->NodeGuid.ToString(); +} + +FString UMCPGraphLibrary::SpawnCallDelegateNode(UBlueprint* Blueprint, UEdGraph* Graph, FName DispatcherName, FVector2D Position) +{ + if (!Blueprint || !Graph) return FString(); + UClass* OwnerClass = Blueprint->GeneratedClass ? Blueprint->GeneratedClass : Blueprint->ParentClass; + if (!OwnerClass) return FString(); + FMulticastDelegateProperty* DelegateProp = FindFProperty(OwnerClass, DispatcherName); + if (!DelegateProp) return FString(); + + UK2Node_CallDelegate* Node = NewObject(Graph); + Node->CreateNewGuid(); + Node->SetFromProperty(DelegateProp, /*bSelfContext*/ true, OwnerClass); + Node->NodePosX = static_cast(Position.X); + Node->NodePosY = static_cast(Position.Y); + Graph->AddNode(Node, true, false); + Node->PostPlacedNewNode(); + Node->AllocateDefaultPins(); + MarkBPDirty(Blueprint); + return Node->NodeGuid.ToString(); +} + +FString UMCPGraphLibrary::SpawnAssignDelegateNode(UBlueprint* Blueprint, UEdGraph* Graph, FName ComponentName, FName DelegateName, FVector2D Position) +{ + FObjectProperty* CompProp = nullptr; FMulticastDelegateProperty* DelegateProp = nullptr; + if (!ResolveComponentDelegate(Blueprint, ComponentName, DelegateName, CompProp, DelegateProp)) return FString(); + + UK2Node_AssignDelegate* Node = NewObject(Graph); + Node->CreateNewGuid(); + Node->SetFromProperty(DelegateProp, false, DelegateProp->GetOwnerClass()); + Node->NodePosX = static_cast(Position.X); + Node->NodePosY = static_cast(Position.Y); + Graph->AddNode(Node, true, false); + Node->PostPlacedNewNode(); + Node->AllocateDefaultPins(); + MarkBPDirty(Blueprint); + return Node->NodeGuid.ToString(); +} + +bool UMCPGraphLibrary::AddEventDispatcher(UBlueprint* Blueprint, FName DispatcherName) +{ + if (!Blueprint || DispatcherName.IsNone()) return false; + + // Create the signature graph (void signature; user can add params via UI). + UEdGraph* SignatureGraph = FBlueprintEditorUtils::CreateNewGraph( + Blueprint, DispatcherName, UEdGraph::StaticClass(), UEdGraphSchema_K2::StaticClass()); + if (!SignatureGraph) return false; + SignatureGraph->bEditable = false; + SignatureGraph->bAllowDeletion = true; + + Blueprint->DelegateSignatureGraphs.Add(SignatureGraph); + const UEdGraphSchema_K2* Schema = GetDefault(); + Schema->CreateDefaultNodesForGraph(*SignatureGraph); + + FBlueprintEditorUtils::MarkBlueprintAsStructurallyModified(Blueprint); + return true; +} + +// --------------------------------------------------------------------------- +// Edges & defaults +// --------------------------------------------------------------------------- +UEdGraphNode* UMCPGraphLibrary::FindNodeByGuid(UEdGraph* Graph, const FString& NodeId) +{ + if (!Graph) return nullptr; + FGuid Guid; + if (!FGuid::Parse(NodeId, Guid)) return nullptr; + for (UEdGraphNode* N : Graph->Nodes) { if (N && N->NodeGuid == Guid) return N; } + return nullptr; +} + +UEdGraphPin* UMCPGraphLibrary::FindPinByName(UEdGraphNode* Node, const FName& PinName) +{ + if (!Node) return nullptr; + return Node->FindPin(PinName); +} + +bool UMCPGraphLibrary::ConnectPins(UEdGraph* Graph, const FString& FromNodeId, FName FromPin, const FString& ToNodeId, FName ToPin) +{ + if (!Graph) return false; + UEdGraphNode* A = FindNodeByGuid(Graph, FromNodeId); + UEdGraphNode* B = FindNodeByGuid(Graph, ToNodeId); + if (!A || !B) return false; + UEdGraphPin* PA = FindPinByName(A, FromPin); + UEdGraphPin* PB = FindPinByName(B, ToPin); + if (!PA || !PB) return false; + const UEdGraphSchema* Schema = Graph->GetSchema(); + if (!Schema) return false; + return Schema->TryCreateConnection(PA, PB); +} + +bool UMCPGraphLibrary::BreakLink(UEdGraph* Graph, const FString& FromNodeId, FName FromPin, const FString& ToNodeId, FName ToPin) +{ + if (!Graph) return false; + UEdGraphNode* A = FindNodeByGuid(Graph, FromNodeId); + UEdGraphNode* B = FindNodeByGuid(Graph, ToNodeId); + if (!A || !B) return false; + UEdGraphPin* PA = FindPinByName(A, FromPin); + UEdGraphPin* PB = FindPinByName(B, ToPin); + if (!PA || !PB) return false; + PA->BreakLinkTo(PB); + return true; +} + +bool UMCPGraphLibrary::BreakAllNodeLinks(UEdGraph* Graph, const FString& NodeId) +{ + UEdGraphNode* N = FindNodeByGuid(Graph, NodeId); + if (!N) return false; + N->BreakAllNodeLinks(); + return true; +} + +bool UMCPGraphLibrary::SetPinDefault(UEdGraph* Graph, const FString& NodeId, FName PinName, const FString& Value) +{ + if (!Graph) return false; + UEdGraphNode* N = FindNodeByGuid(Graph, NodeId); + if (!N) return false; + UEdGraphPin* P = FindPinByName(N, PinName); + if (!P) return false; + const UEdGraphSchema* Schema = Graph->GetSchema(); + if (!Schema) return false; + Schema->TrySetDefaultValue(*P, Value); + return true; +} + +// --------------------------------------------------------------------------- +// BP-level authoring +// --------------------------------------------------------------------------- +UEdGraph* UMCPGraphLibrary::AddFunctionGraph(UBlueprint* Blueprint, FName FunctionName) +{ + if (!Blueprint || FunctionName.IsNone()) return nullptr; + UEdGraph* NewGraph = FBlueprintEditorUtils::CreateNewGraph( + Blueprint, FunctionName, UEdGraph::StaticClass(), UEdGraphSchema_K2::StaticClass()); + if (!NewGraph) return nullptr; + FBlueprintEditorUtils::AddFunctionGraph(Blueprint, NewGraph, /*bIsUserCreated*/ true, (UClass*)nullptr); + return NewGraph; +} + +UEdGraph* UMCPGraphLibrary::AddMacroGraph(UBlueprint* Blueprint, FName MacroName) +{ + if (!Blueprint || MacroName.IsNone()) return nullptr; + UEdGraph* NewGraph = FBlueprintEditorUtils::CreateNewGraph( + Blueprint, MacroName, UEdGraph::StaticClass(), UEdGraphSchema_K2::StaticClass()); + if (!NewGraph) return nullptr; + FBlueprintEditorUtils::AddMacroGraph(Blueprint, NewGraph, /*bIsUserCreated*/ true, (UClass*)nullptr); + return NewGraph; +} + +bool UMCPGraphLibrary::AddInterface(UBlueprint* Blueprint, const FString& InterfacePath) +{ + if (!Blueprint || InterfacePath.IsEmpty()) return false; + return FBlueprintEditorUtils::ImplementNewInterface(Blueprint, FTopLevelAssetPath(InterfacePath)); +} + +bool UMCPGraphLibrary::SetVariableDefaultValue(UBlueprint* Blueprint, FName VariableName, const FString& Value) +{ + if (!Blueprint || VariableName.IsNone()) return false; + for (FBPVariableDescription& Var : Blueprint->NewVariables) + { + if (Var.VarName == VariableName) + { + Var.DefaultValue = Value; + MarkBPDirty(Blueprint); + return true; + } + } + return false; +} + +bool UMCPGraphLibrary::SetVariableMetadata(UBlueprint* Blueprint, FName VariableName, bool bInstanceEditable, bool bExposeOnSpawn, const FString& Category, const FString& Tooltip) +{ + if (!Blueprint || VariableName.IsNone()) return false; + + // FBlueprintEditorUtils doesn't expose dedicated InstanceEditable/ExposeOnSpawn + // setters as standalone fns in 5.7. Manipulate FBPVariableDescription directly. + bool bFound = false; + for (FBPVariableDescription& Var : Blueprint->NewVariables) + { + if (Var.VarName != VariableName) continue; + bFound = true; + if (bInstanceEditable) Var.PropertyFlags &= ~CPF_DisableEditOnInstance; + else Var.PropertyFlags |= CPF_DisableEditOnInstance; + // NOTE: UE treats the mere PRESENCE of the "ExposeOnSpawn" metadata key as + // "exposed" (the value "false" does NOT un-expose it). Remove the key when + // disabling, only add it when enabling. + if (bExposeOnSpawn) + { + Var.SetMetaData(TEXT("ExposeOnSpawn"), TEXT("true")); + Var.PropertyFlags |= CPF_ExposeOnSpawn; + } + else + { + Var.RemoveMetaData(TEXT("ExposeOnSpawn")); + Var.PropertyFlags &= ~CPF_ExposeOnSpawn; + } + break; + } + if (!bFound) return false; + + if (!Category.IsEmpty()) + { + FBlueprintEditorUtils::SetBlueprintVariableCategory(Blueprint, VariableName, nullptr, FText::FromString(Category), false); + } + if (!Tooltip.IsEmpty()) + { + FBlueprintEditorUtils::SetBlueprintVariableMetaData(Blueprint, VariableName, nullptr, TEXT("Tooltip"), Tooltip); + } + MarkBPDirty(Blueprint); + return true; +} + +bool UMCPGraphLibrary::AddLocalVariable(UBlueprint* Blueprint, FName FunctionName, FName VariableName, const FString& TypeSpec, const FString& DefaultValue) +{ + if (!Blueprint || FunctionName.IsNone() || VariableName.IsNone()) return false; + + UEdGraph* FuncGraph = FindFunctionGraph(Blueprint, FunctionName); + if (!FuncGraph) return false; + + FEdGraphPinType PinType; + const FString T = TypeSpec.ToLower(); + if (T == TEXT("bool")) PinType.PinCategory = UEdGraphSchema_K2::PC_Boolean; + else if (T == TEXT("int")) PinType.PinCategory = UEdGraphSchema_K2::PC_Int; + else if (T == TEXT("float")) { PinType.PinCategory = UEdGraphSchema_K2::PC_Real; PinType.PinSubCategory = UEdGraphSchema_K2::PC_Float; } + else if (T == TEXT("string")) PinType.PinCategory = UEdGraphSchema_K2::PC_String; + else if (T == TEXT("name")) PinType.PinCategory = UEdGraphSchema_K2::PC_Name; + else if (T == TEXT("text")) PinType.PinCategory = UEdGraphSchema_K2::PC_Text; + else + { + UClass* Cls = TypeSpec.Contains(TEXT("/")) ? LoadObject(nullptr, *TypeSpec) + : FindFirstObject(*TypeSpec, EFindFirstObjectOptions::None); + if (!Cls) return false; + PinType.PinCategory = UEdGraphSchema_K2::PC_Object; + PinType.PinSubCategoryObject = Cls; + } + + return FBlueprintEditorUtils::AddLocalVariable(Blueprint, FuncGraph, VariableName, PinType, DefaultValue); +} + +FString UMCPGraphLibrary::AddComponentToBlueprint(UBlueprint* Blueprint, UClass* ComponentClass, FName ComponentName) +{ + if (!Blueprint || !ComponentClass) return FString(); + if (!ComponentClass->IsChildOf(UActorComponent::StaticClass())) return FString(); + USimpleConstructionScript* SCS = Blueprint->SimpleConstructionScript; + if (!SCS) return FString(); + for (USCS_Node* Existing : SCS->GetAllNodes()) + { + if (Existing && Existing->GetVariableName() == ComponentName) return Existing->GetVariableName().ToString(); + } + USCS_Node* NewNode = SCS->CreateNode(ComponentClass, ComponentName); + if (!NewNode) return FString(); + SCS->AddNode(NewNode); + FBlueprintEditorUtils::MarkBlueprintAsStructurallyModified(Blueprint); + return NewNode->GetVariableName().ToString(); +} + +bool UMCPGraphLibrary::AddMemberVariable(UBlueprint* Blueprint, FName VariableName, const FString& TypeSpec) +{ + if (!Blueprint || VariableName.IsNone() || TypeSpec.IsEmpty()) return false; + FEdGraphPinType PinType; + const FString T = TypeSpec.ToLower(); + if (T == TEXT("bool")) PinType.PinCategory = UEdGraphSchema_K2::PC_Boolean; + else if (T == TEXT("int")) PinType.PinCategory = UEdGraphSchema_K2::PC_Int; + else if (T == TEXT("float")) { PinType.PinCategory = UEdGraphSchema_K2::PC_Real; PinType.PinSubCategory = UEdGraphSchema_K2::PC_Float; } + else if (T == TEXT("double")) { PinType.PinCategory = UEdGraphSchema_K2::PC_Real; PinType.PinSubCategory = UEdGraphSchema_K2::PC_Double; } + else if (T == TEXT("string")) PinType.PinCategory = UEdGraphSchema_K2::PC_String; + else if (T == TEXT("name")) PinType.PinCategory = UEdGraphSchema_K2::PC_Name; + else if (T == TEXT("text")) PinType.PinCategory = UEdGraphSchema_K2::PC_Text; + else + { + UClass* Cls = TypeSpec.Contains(TEXT("/")) ? LoadObject(nullptr, *TypeSpec) + : FindFirstObject(*TypeSpec, EFindFirstObjectOptions::None); + if (!Cls) return false; + PinType.PinCategory = UEdGraphSchema_K2::PC_Object; + PinType.PinSubCategoryObject = Cls; + } + return FBlueprintEditorUtils::AddMemberVariable(Blueprint, VariableName, PinType); +} + +USCS_Node* UMCPGraphLibrary::FindSCSNode(UBlueprint* Blueprint, FName ComponentName) +{ + if (!Blueprint || !Blueprint->SimpleConstructionScript) return nullptr; + for (USCS_Node* N : Blueprint->SimpleConstructionScript->GetAllNodes()) + { + if (N && N->GetVariableName() == ComponentName) return N; + } + return nullptr; +} + +bool UMCPGraphLibrary::RemoveComponent(UBlueprint* Blueprint, FName ComponentName) +{ + USCS_Node* N = FindSCSNode(Blueprint, ComponentName); + if (!N) return false; + Blueprint->SimpleConstructionScript->RemoveNode(N); + FBlueprintEditorUtils::MarkBlueprintAsStructurallyModified(Blueprint); + return true; +} + +bool UMCPGraphLibrary::AttachComponent(UBlueprint* Blueprint, FName ChildName, FName ParentName, FName SocketName) +{ + USCS_Node* Child = FindSCSNode(Blueprint, ChildName); + USCS_Node* Parent = FindSCSNode(Blueprint, ParentName); + if (!Child || !Parent) return false; + + USimpleConstructionScript* SCS = Blueprint->SimpleConstructionScript; + // Detach from current parent + for (USCS_Node* N : SCS->GetAllNodes()) + { + if (N) N->RemoveChildNode(Child, /*bRecursive*/ false); + } + SCS->RemoveNode(Child); + // Re-add and attach + Parent->AddChildNode(Child); + if (!SocketName.IsNone()) Child->AttachToName = SocketName; + FBlueprintEditorUtils::MarkBlueprintAsStructurallyModified(Blueprint); + return true; +} + +bool UMCPGraphLibrary::SetRootComponent(UBlueprint* Blueprint, FName ComponentName) +{ + USCS_Node* Target = FindSCSNode(Blueprint, ComponentName); + if (!Target) return false; + USimpleConstructionScript* SCS = Blueprint->SimpleConstructionScript; + + // Detach from any existing parent first, then re-add as a top-level root. + // Note: SCS::RootNodes is private and has no public reorder API in 5.7, so + // the node becomes "a" root, not necessarily the FIRST root. For most BPs + // the existing root is a DefaultSceneRoot which the SCS auto-replaces when + // a new scene-root candidate appears. + for (USCS_Node* N : SCS->GetAllNodes()) + { + if (N) N->RemoveChildNode(Target, /*bRecursive*/ false); + } + SCS->RemoveNode(Target); + SCS->AddNode(Target); + FBlueprintEditorUtils::MarkBlueprintAsStructurallyModified(Blueprint); + return true; +} + +bool UMCPGraphLibrary::SetComponentProperty(UBlueprint* Blueprint, FName ComponentName, FName PropertyName, const FString& Value) +{ + USCS_Node* N = FindSCSNode(Blueprint, ComponentName); + if (!N || !N->ComponentTemplate) return false; + UActorComponent* Tmpl = N->ComponentTemplate; + FProperty* Prop = FindFProperty(Tmpl->GetClass(), PropertyName); + if (!Prop) return false; + void* ValuePtr = Prop->ContainerPtrToValuePtr(Tmpl); + if (!ValuePtr) return false; + Prop->ImportText_Direct(*Value, ValuePtr, Tmpl, PPF_None); + FBlueprintEditorUtils::MarkBlueprintAsModified(Blueprint); + return true; +} + +bool UMCPGraphLibrary::SetComponentTransform(UBlueprint* Blueprint, FName ComponentName, FVector Location, FRotator Rotation, FVector Scale) +{ + USCS_Node* N = FindSCSNode(Blueprint, ComponentName); + if (!N) return false; + USceneComponent* Scene = Cast(N->ComponentTemplate); + if (!Scene) return false; + Scene->SetRelativeLocation(Location); + Scene->SetRelativeRotation(Rotation); + Scene->SetRelativeScale3D(Scale); + FBlueprintEditorUtils::MarkBlueprintAsModified(Blueprint); + return true; +} + +bool UMCPGraphLibrary::ConfigureBoxComponent(UBlueprint* Blueprint, FName ComponentName, FVector Extent, FName CollisionProfile) +{ + USCS_Node* N = FindSCSNode(Blueprint, ComponentName); + if (!N) return false; + UBoxComponent* Box = Cast(N->ComponentTemplate); + if (!Box) return false; + Box->SetBoxExtent(Extent, false); + if (!CollisionProfile.IsNone()) Box->SetCollisionProfileName(CollisionProfile); + Box->SetGenerateOverlapEvents(true); + FBlueprintEditorUtils::MarkBlueprintAsModified(Blueprint); + return true; +} + +// --------------------------------------------------------------------------- +// Compile / introspect +// --------------------------------------------------------------------------- +bool UMCPGraphLibrary::CompileAndSaveBlueprint(UBlueprint* Blueprint) +{ + if (!Blueprint) return false; + FKismetEditorUtilities::CompileBlueprint(Blueprint); + return true; +} + +TArray UMCPGraphLibrary::GetCompileErrors(UBlueprint* Blueprint) +{ + TArray Out; + if (!Blueprint) return Out; + // BP keeps status — return high-level state for now. Detailed messages + // require capturing FCompilerResultsLog during compile, which we do not + // wire up here; this still gives a quick health signal. + switch (Blueprint->Status) + { + case BS_UpToDate: Out.Add(TEXT("UpToDate")); break; + case BS_Dirty: Out.Add(TEXT("Dirty")); break; + case BS_Error: Out.Add(TEXT("Error")); break; + case BS_UpToDateWithWarnings: Out.Add(TEXT("UpToDateWithWarnings")); break; + case BS_Unknown: Out.Add(TEXT("Unknown")); break; + case BS_BeingCreated: Out.Add(TEXT("BeingCreated")); break; + default: Out.Add(TEXT("Other")); break; + } + return Out; +} + +TArray UMCPGraphLibrary::ListFunctionsOnClass(UClass* Class) +{ + TArray Out; + if (!Class) return Out; + for (TFieldIterator It(Class); It; ++It) + { + UFunction* F = *It; + if (!F) continue; + Out.Add(F->GetName()); + } + return Out; +} + +TArray UMCPGraphLibrary::ListPropertiesOnClass(UClass* Class) +{ + TArray Out; + if (!Class) return Out; + for (TFieldIterator It(Class); It; ++It) + { + FProperty* P = *It; + if (!P) continue; + Out.Add(FString::Printf(TEXT("%s|%s"), *P->GetName(), *P->GetCPPType())); + } + return Out; +} + +TArray UMCPGraphLibrary::ListDelegatesOnClass(UClass* Class) +{ + TArray Out; + if (!Class) return Out; + for (TFieldIterator It(Class); It; ++It) + { + FMulticastDelegateProperty* D = *It; + if (!D) continue; + Out.Add(D->GetName()); + } + return Out; +} + +FString UMCPGraphLibrary::GetPinType(UClass* OwnerClass, FName FunctionName, FName PinName) +{ + if (!OwnerClass) return FString(); + UFunction* Fn = OwnerClass->FindFunctionByName(FunctionName); + if (!Fn) return FString(); + FProperty* Param = Fn->FindPropertyByName(PinName); + if (!Param) return FString(); + return Param->GetCPPType(); +} + +// =========================================================================== +// Tier 2 spawners +// =========================================================================== +FString UMCPGraphLibrary::SpawnKnotNode(UBlueprint* Blueprint, UEdGraph* Graph, FVector2D Position) +{ + UK2Node_Knot* Node = PlaceNode(Graph, Position); + if (!Node) return FString(); + MarkBPDirty(Blueprint); + return Node->NodeGuid.ToString(); +} + +FString UMCPGraphLibrary::SpawnCommentNode(UBlueprint* Blueprint, UEdGraph* Graph, FVector2D Position, FVector2D Size, const FString& Comment) +{ + if (!Graph) return FString(); + UEdGraphNode_Comment* Node = NewObject(Graph); + Node->CreateNewGuid(); + Node->NodePosX = static_cast(Position.X); + Node->NodePosY = static_cast(Position.Y); + Node->NodeWidth = FMath::Max(64, static_cast(Size.X)); + Node->NodeHeight = FMath::Max(64, static_cast(Size.Y)); + Node->NodeComment = Comment; + Graph->AddNode(Node, true, false); + Node->PostPlacedNewNode(); + Node->AllocateDefaultPins(); + MarkBPDirty(Blueprint); + return Node->NodeGuid.ToString(); +} + +FString UMCPGraphLibrary::SpawnEnumLiteralNode(UBlueprint* Blueprint, UEdGraph* Graph, UEnum* EnumType, FVector2D Position) +{ + if (!Graph || !EnumType) return FString(); + UK2Node_EnumLiteral* Node = NewObject(Graph); + Node->CreateNewGuid(); + Node->Enum = EnumType; + Node->NodePosX = static_cast(Position.X); + Node->NodePosY = static_cast(Position.Y); + Graph->AddNode(Node, true, false); + Node->PostPlacedNewNode(); + Node->AllocateDefaultPins(); + MarkBPDirty(Blueprint); + return Node->NodeGuid.ToString(); +} + +FString UMCPGraphLibrary::SpawnGetClassDefaultsNode(UBlueprint* Blueprint, UEdGraph* Graph, UClass* TargetClass, FVector2D Position) +{ + if (!Graph || !TargetClass) return FString(); + UK2Node_GetClassDefaults* Node = NewObject(Graph); + Node->CreateNewGuid(); + Node->NodePosX = static_cast(Position.X); + Node->NodePosY = static_cast(Position.Y); + Graph->AddNode(Node, true, false); + Node->PostPlacedNewNode(); + Node->AllocateDefaultPins(); + // Set the class on the input pin + if (UEdGraphPin* ClassPin = Node->FindPin(TEXT("Class"))) + { + Graph->GetSchema()->TrySetDefaultObject(*ClassPin, TargetClass); + Node->ReconstructNode(); + } + MarkBPDirty(Blueprint); + return Node->NodeGuid.ToString(); +} + +FString UMCPGraphLibrary::SpawnFunctionResultNode(UBlueprint* Blueprint, UEdGraph* Graph, FVector2D Position) +{ + UK2Node_FunctionResult* Node = PlaceNode(Graph, Position); + if (!Node) return FString(); + MarkBPDirty(Blueprint); + return Node->NodeGuid.ToString(); +} + +FString UMCPGraphLibrary::SpawnGetArrayItemNode(UBlueprint* Blueprint, UEdGraph* Graph, FVector2D Position) +{ + UK2Node_GetArrayItem* Node = PlaceNode(Graph, Position); + if (!Node) return FString(); + MarkBPDirty(Blueprint); + return Node->NodeGuid.ToString(); +} + +FString UMCPGraphLibrary::SpawnPromotableOperatorNode(UBlueprint* Blueprint, UEdGraph* Graph, FName OperatorName, FVector2D Position) +{ + // Promotable ops in UE are really KismetMathLibrary call_function nodes (Add_IntInt, + // Subtract_FloatFloat, etc.) that the editor auto-promotes. Easiest portable impl: + // route to KismetMathLibrary by symbolic operator -> wildcard function name. + if (!Blueprint || !Graph) return FString(); + static const TMap OpMap = { + { TEXT("+"), TEXT("Add_IntInt") }, + { TEXT("-"), TEXT("Subtract_IntInt") }, + { TEXT("*"), TEXT("Multiply_IntInt") }, + { TEXT("/"), TEXT("Divide_IntInt") }, + { TEXT("=="), TEXT("EqualEqual_IntInt") }, + { TEXT("!="), TEXT("NotEqual_IntInt") }, + { TEXT("<"), TEXT("Less_IntInt") }, + { TEXT(">"), TEXT("Greater_IntInt") }, + { TEXT("<="), TEXT("LessEqual_IntInt") }, + { TEXT(">="), TEXT("GreaterEqual_IntInt") }, + }; + const FName* Mapped = OpMap.Find(OperatorName); + if (!Mapped) return FString(); + UClass* KML = LoadClass(nullptr, TEXT("/Script/Engine.KismetMathLibrary")); + if (!KML) return FString(); + return SpawnCallFunctionNode(Blueprint, Graph, KML, *Mapped, Position); +} + +bool UMCPGraphLibrary::AddCasePinToSwitch(UEdGraph* Graph, const FString& NodeId) +{ + UEdGraphNode* N = FindNodeByGuid(Graph, NodeId); + if (!N) return false; + UK2Node_Switch* Sw = Cast(N); + if (!Sw) return false; + Sw->AddPinToSwitchNode(); + Sw->ReconstructNode(); + if (UBlueprint* BP = FBlueprintEditorUtils::FindBlueprintForGraph(Graph)) MarkBPDirty(BP); + return true; +} + +bool UMCPGraphLibrary::FormatTextSetFormat(UEdGraph* Graph, const FString& NodeId, const FString& Format) +{ + UEdGraphNode* N = FindNodeByGuid(Graph, NodeId); + if (!N) return false; + UK2Node_FormatText* FT = Cast(N); + if (!FT) return false; + UEdGraphPin* FormatPin = FT->FindPin(TEXT("Format")); + if (!FormatPin) return false; + Graph->GetSchema()->TrySetDefaultText(*FormatPin, FText::FromString(Format)); + FT->PinDefaultValueChanged(FormatPin); + FT->ReconstructNode(); + if (UBlueprint* BP = FBlueprintEditorUtils::FindBlueprintForGraph(Graph)) MarkBPDirty(BP); + return true; +} + +bool UMCPGraphLibrary::ReconstructNode(UEdGraph* Graph, const FString& NodeId) +{ + UEdGraphNode* N = FindNodeByGuid(Graph, NodeId); + if (!N) return false; + N->ReconstructNode(); + if (UBlueprint* BP = FBlueprintEditorUtils::FindBlueprintForGraph(Graph)) MarkBPDirty(BP); + return true; +} + +// =========================================================================== +// Authoring — rename / delete / function params +// =========================================================================== +bool UMCPGraphLibrary::RenameVariable(UBlueprint* Blueprint, FName OldName, FName NewName) +{ + if (!Blueprint || OldName.IsNone() || NewName.IsNone()) return false; + FBlueprintEditorUtils::RenameMemberVariable(Blueprint, OldName, NewName); + return true; +} + +bool UMCPGraphLibrary::DeleteVariable(UBlueprint* Blueprint, FName VariableName) +{ + if (!Blueprint || VariableName.IsNone()) return false; + FBlueprintEditorUtils::RemoveMemberVariable(Blueprint, VariableName); + return true; +} + +bool UMCPGraphLibrary::RenameFunction(UBlueprint* Blueprint, FName OldName, FName NewName) +{ + if (!Blueprint || OldName.IsNone() || NewName.IsNone()) return false; + UEdGraph* G = FindFunctionGraph(Blueprint, OldName); + if (!G) return false; + FBlueprintEditorUtils::RenameGraph(G, NewName.ToString()); + return true; +} + +bool UMCPGraphLibrary::RemoveFunctionGraph(UBlueprint* Blueprint, FName FunctionName) +{ + if (!Blueprint || FunctionName.IsNone()) return false; + UEdGraph* G = nullptr; + for (UEdGraph* Fg : Blueprint->FunctionGraphs) { if (Fg && Fg->GetFName() == FunctionName) { G = Fg; break; } } + if (!G) return false; + FBlueprintEditorUtils::RemoveGraph(Blueprint, G, EGraphRemoveFlags::Recompile); + return true; +} + +bool UMCPGraphLibrary::RemoveMacroGraph(UBlueprint* Blueprint, FName MacroName) +{ + if (!Blueprint || MacroName.IsNone()) return false; + UEdGraph* G = nullptr; + for (UEdGraph* Mg : Blueprint->MacroGraphs) { if (Mg && Mg->GetFName() == MacroName) { G = Mg; break; } } + if (!G) return false; + FBlueprintEditorUtils::RemoveGraph(Blueprint, G, EGraphRemoveFlags::Recompile); + return true; +} + +static bool ParsePinTypeFromSpec(const FString& TypeSpec, FEdGraphPinType& OutPinType) +{ + const FString T = TypeSpec.ToLower(); + if (T == TEXT("bool")) OutPinType.PinCategory = UEdGraphSchema_K2::PC_Boolean; + else if (T == TEXT("int")) OutPinType.PinCategory = UEdGraphSchema_K2::PC_Int; + else if (T == TEXT("float")) { OutPinType.PinCategory = UEdGraphSchema_K2::PC_Real; OutPinType.PinSubCategory = UEdGraphSchema_K2::PC_Float; } + else if (T == TEXT("double")) { OutPinType.PinCategory = UEdGraphSchema_K2::PC_Real; OutPinType.PinSubCategory = UEdGraphSchema_K2::PC_Double; } + else if (T == TEXT("string")) OutPinType.PinCategory = UEdGraphSchema_K2::PC_String; + else if (T == TEXT("name")) OutPinType.PinCategory = UEdGraphSchema_K2::PC_Name; + else if (T == TEXT("text")) OutPinType.PinCategory = UEdGraphSchema_K2::PC_Text; + else + { + UClass* Cls = TypeSpec.Contains(TEXT("/")) ? LoadObject(nullptr, *TypeSpec) + : FindFirstObject(*TypeSpec, EFindFirstObjectOptions::None); + if (!Cls) return false; + OutPinType.PinCategory = UEdGraphSchema_K2::PC_Object; + OutPinType.PinSubCategoryObject = Cls; + } + return true; +} + +bool UMCPGraphLibrary::AddFunctionParameter(UBlueprint* Blueprint, FName FunctionName, FName ParamName, const FString& TypeSpec, const FString& Direction) +{ + if (!Blueprint || FunctionName.IsNone() || ParamName.IsNone()) return false; + UEdGraph* G = FindFunctionGraph(Blueprint, FunctionName); + if (!G) return false; + + FEdGraphPinType PinType; + if (!ParsePinTypeFromSpec(TypeSpec, PinType)) return false; + + const bool bInput = Direction.IsEmpty() || Direction.Equals(TEXT("input"), ESearchCase::IgnoreCase); + + // Inputs go on the EntryNode; outputs on the ResultNode (created if missing). + UK2Node_EditablePinBase* TargetNode = nullptr; + for (UEdGraphNode* N : G->Nodes) + { + if (bInput && N->IsA()) { TargetNode = Cast(N); break; } + if (!bInput && N->IsA()) { TargetNode = Cast(N); break; } + } + if (!bInput && !TargetNode) + { + // Auto-spawn result node if missing + FString NewGuid = SpawnFunctionResultNode(Blueprint, G, FVector2D(600, 0)); + if (UEdGraphNode* R = FindNodeByGuid(G, NewGuid)) TargetNode = Cast(R); + } + if (!TargetNode) return false; + + TSharedPtr NewPinInfo = MakeShareable(new FUserPinInfo()); + NewPinInfo->PinName = ParamName; + NewPinInfo->PinType = PinType; + NewPinInfo->DesiredPinDirection = bInput ? EGPD_Output : EGPD_Input; + TargetNode->UserDefinedPins.Add(NewPinInfo); + TargetNode->ReconstructNode(); + FBlueprintEditorUtils::MarkBlueprintAsStructurallyModified(Blueprint); + return true; +} + +bool UMCPGraphLibrary::RemoveFunctionParameter(UBlueprint* Blueprint, FName FunctionName, FName ParamName) +{ + if (!Blueprint || FunctionName.IsNone() || ParamName.IsNone()) return false; + UEdGraph* G = FindFunctionGraph(Blueprint, FunctionName); + if (!G) return false; + bool bChanged = false; + for (UEdGraphNode* N : G->Nodes) + { + UK2Node_EditablePinBase* E = Cast(N); + if (!E) continue; + for (int32 i = E->UserDefinedPins.Num() - 1; i >= 0; --i) + { + if (E->UserDefinedPins[i]->PinName == ParamName) + { + E->RemoveUserDefinedPin(E->UserDefinedPins[i]); + bChanged = true; + } + } + if (bChanged) E->ReconstructNode(); + } + if (bChanged) FBlueprintEditorUtils::MarkBlueprintAsStructurallyModified(Blueprint); + return bChanged; +} + +// =========================================================================== +// UMG Designer / WidgetTree +// =========================================================================== +FString UMCPGraphLibrary::DescribeWidgetTreeJson(UWidgetBlueprint* Blueprint) +{ + TSharedRef Root = MakeShared(); + UWidgetTree* Tree = MCPGetWidgetTree(Blueprint); + if (!Blueprint || !Tree) + { + Root->SetBoolField(TEXT("ok"), false); + Root->SetStringField(TEXT("error"), TEXT("not a WidgetBlueprint or missing WidgetTree")); + return MCPWidgetJsonStringify(Root); + } + + Root->SetBoolField(TEXT("ok"), true); + Root->SetStringField(TEXT("path"), Blueprint->GetPathName()); + if (Tree->RootWidget) + { + Root->SetStringField(TEXT("root"), Tree->RootWidget->GetFName().ToString()); + } + + TArray Widgets; + Tree->GetAllWidgets(Widgets); + TArray> Items; + for (UWidget* Widget : Widgets) + { + if (Widget) + { + Items.Add(MakeShared(WidgetToJson(Widget))); + } + } + Root->SetArrayField(TEXT("widgets"), Items); + Root->SetNumberField(TEXT("count"), Items.Num()); + return MCPWidgetJsonStringify(Root); +} + +FString UMCPGraphLibrary::GetWidgetPropertiesJson(UWidgetBlueprint* Blueprint, FName WidgetName) +{ + return MCPWidgetJsonStringify(ExportObjectPropertiesJson(MCPFindWidget(Blueprint, WidgetName))); +} + +FString UMCPGraphLibrary::GetWidgetSlotPropertiesJson(UWidgetBlueprint* Blueprint, FName WidgetName) +{ + UWidget* Widget = MCPFindWidget(Blueprint, WidgetName); + return MCPWidgetJsonStringify(ExportObjectPropertiesJson(Widget ? Widget->Slot : nullptr)); +} + +FString UMCPGraphLibrary::AddWidgetToTree(UWidgetBlueprint* Blueprint, UClass* WidgetClass, FName WidgetName, FName ParentName, int32 Index, bool bIsVariable) +{ + UWidgetTree* Tree = MCPGetWidgetTree(Blueprint); + if (!Blueprint || !Tree || !WidgetClass || !WidgetClass->IsChildOf(UWidget::StaticClass())) + { + return FString(); + } + + FName FinalName = WidgetName; + if (FinalName.IsNone()) + { + FinalName = MakeUniqueObjectName(Tree, WidgetClass, WidgetClass->GetFName()); + } + else if (Tree->FindWidget(FinalName)) + { + return FString(); + } + + UWidget* Parent = ParentName.IsNone() ? nullptr : MCPFindWidget(Blueprint, ParentName); + if (!ParentName.IsNone() && !Parent) + { + return FString(); + } + + Tree->SetFlags(RF_Transactional); + Tree->Modify(); + UWidget* Widget = Tree->ConstructWidget(WidgetClass, FinalName); + if (!Widget) + { + return FString(); + } + Widget->SetFlags(RF_Transactional); + Widget->CreatedFromPalette(); + Widget->bIsVariable = bIsVariable; + + if (!AddExistingWidgetToParent(Blueprint, Widget, Parent, Index)) + { + Tree->RemoveWidget(Widget); + return FString(); + } + + if (Widget->bIsVariable) + { + Blueprint->OnVariableAdded(Widget->GetFName()); + } + MarkWidgetBPDirty(Blueprint); + return Widget->GetFName().ToString(); +} + +bool UMCPGraphLibrary::RemoveWidgetFromTree(UWidgetBlueprint* Blueprint, FName WidgetName) +{ + UWidget* Widget = MCPFindWidget(Blueprint, WidgetName); + if (!Blueprint || !Widget) + { + return false; + } + const bool bWasVariable = Widget->bIsVariable; + const FName RemovedName = Widget->GetFName(); + TSet Widgets; + Widgets.Add(Widget); + FWidgetBlueprintEditorUtils::DeleteWidgets(Blueprint, Widgets, FWidgetBlueprintEditorUtils::EDeleteWidgetWarningType::DeleteSilently); + if (bWasVariable) + { + Blueprint->OnVariableRemoved(RemovedName); + } + MarkWidgetBPDirty(Blueprint); + return true; +} + +bool UMCPGraphLibrary::RenameWidgetInTree(UWidgetBlueprint* Blueprint, FName OldName, FName NewName) +{ + UWidgetTree* Tree = MCPGetWidgetTree(Blueprint); + UWidget* Widget = MCPFindWidget(Blueprint, OldName); + if (!Blueprint || !Tree || !Widget || NewName.IsNone() || Tree->FindWidget(NewName)) + { + return false; + } + + const bool bWasVariable = Widget->bIsVariable; + Widget->SetFlags(RF_Transactional); + Widget->Modify(); + const FName PreviousName = Widget->GetFName(); + if (!Widget->Rename(*NewName.ToString(), Tree, REN_DontCreateRedirectors)) + { + return false; + } + Widget->SetDisplayLabel(TEXT("")); + if (bWasVariable) + { + Blueprint->OnVariableRenamed(PreviousName, NewName); + } + MarkWidgetBPDirty(Blueprint); + return true; +} + +bool UMCPGraphLibrary::MoveWidgetInTree(UWidgetBlueprint* Blueprint, FName WidgetName, FName NewParentName, int32 Index) +{ + UWidgetTree* Tree = MCPGetWidgetTree(Blueprint); + UWidget* Widget = MCPFindWidget(Blueprint, WidgetName); + UWidget* NewParent = NewParentName.IsNone() ? nullptr : MCPFindWidget(Blueprint, NewParentName); + if (!Blueprint || !Tree || !Widget || (!NewParentName.IsNone() && !NewParent) || Widget == NewParent) + { + return false; + } + if (NewParent && NewParent->IsChildOf(Widget)) + { + return false; + } + + Tree->Modify(); + if (Widget == Tree->RootWidget) + { + Tree->RootWidget = nullptr; + } + else if (UPanelWidget* OldParent = Cast(Widget->Slot ? Widget->Slot->Parent : nullptr)) + { + OldParent->Modify(); + OldParent->RemoveChild(Widget); + } + else + { + Tree->RemoveWidget(Widget); + UWidgetTree::TryMoveWidgetToNewTree(Widget, Tree); + } + + if (!AddExistingWidgetToParent(Blueprint, Widget, NewParent, Index)) + { + return false; + } + MarkWidgetBPDirty(Blueprint); + return true; +} + +bool UMCPGraphLibrary::SetRootWidget(UWidgetBlueprint* Blueprint, FName WidgetName) +{ + UWidgetTree* Tree = MCPGetWidgetTree(Blueprint); + UWidget* Widget = MCPFindWidget(Blueprint, WidgetName); + if (!Blueprint || !Tree || !Widget) + { + return false; + } + Tree->Modify(); + if (UPanelWidget* OldParent = Cast(Widget->Slot ? Widget->Slot->Parent : nullptr)) + { + OldParent->Modify(); + OldParent->RemoveChild(Widget); + } + Tree->RootWidget = Widget; + MarkWidgetBPDirty(Blueprint); + return true; +} + +bool UMCPGraphLibrary::SetWidgetIsVariable(UWidgetBlueprint* Blueprint, FName WidgetName, bool bIsVariable) +{ + UWidget* Widget = MCPFindWidget(Blueprint, WidgetName); + if (!Blueprint || !Widget || Widget->bIsVariable == bIsVariable) + { + return Widget != nullptr; + } + Widget->Modify(); + Widget->bIsVariable = bIsVariable; + if (bIsVariable) + { + Blueprint->OnVariableAdded(Widget->GetFName()); + } + else + { + Blueprint->OnVariableRemoved(Widget->GetFName()); + } + MarkWidgetBPDirty(Blueprint); + return true; +} + +bool UMCPGraphLibrary::SetWidgetProperty(UWidgetBlueprint* Blueprint, FName WidgetName, FName PropertyName, const FString& Value) +{ + UWidget* Widget = MCPFindWidget(Blueprint, WidgetName); + const bool bOk = SetObjectPropertyFromString(Widget, PropertyName, Value); + if (bOk) + { + MarkWidgetBPDirty(Blueprint, false); + } + return bOk; +} + +bool UMCPGraphLibrary::SetWidgetSlotProperty(UWidgetBlueprint* Blueprint, FName WidgetName, FName PropertyName, const FString& Value) +{ + UWidget* Widget = MCPFindWidget(Blueprint, WidgetName); + const bool bOk = SetObjectPropertyFromString(Widget ? Widget->Slot : nullptr, PropertyName, Value); + if (bOk) + { + MarkWidgetBPDirty(Blueprint, false); + } + return bOk; +} + +bool UMCPGraphLibrary::SetCanvasSlotLayout(UWidgetBlueprint* Blueprint, FName WidgetName, FVector2D Position, FVector2D Size, FVector2D AnchorsMinimum, FVector2D AnchorsMaximum, FVector2D Alignment, bool bAutoSize, int32 ZOrder) +{ + UWidget* Widget = MCPFindWidget(Blueprint, WidgetName); + UCanvasPanelSlot* Slot = Widget ? Cast(Widget->Slot) : nullptr; + if (!Slot) + { + return false; + } + Slot->SetFlags(RF_Transactional); + Slot->Modify(); + Slot->SetPosition(Position); + Slot->SetSize(Size); + Slot->SetAnchors(FAnchors(AnchorsMinimum.X, AnchorsMinimum.Y, AnchorsMaximum.X, AnchorsMaximum.Y)); + Slot->SetAlignment(Alignment); + Slot->SetAutoSize(bAutoSize); + Slot->SetZOrder(ZOrder); + Slot->SynchronizeProperties(); + MarkWidgetBPDirty(Blueprint, false); + return true; +} + +bool UMCPGraphLibrary::SetNamedSlotContent(UWidgetBlueprint* Blueprint, FName HostWidgetName, FName SlotName, FName ContentWidgetName) +{ + UWidget* Host = MCPFindWidget(Blueprint, HostWidgetName); + UWidget* Content = MCPFindWidget(Blueprint, ContentWidgetName); + INamedSlotInterface* NamedSlots = Cast(Host); + if (!Blueprint || !NamedSlots || !Content || SlotName.IsNone()) + { + return false; + } + Host->Modify(); + Content->Modify(); + NamedSlots->SetContentForSlot(SlotName, Content); + MarkWidgetBPDirty(Blueprint); + return true; +} + +bool UMCPGraphLibrary::ClearNamedSlotContent(UWidgetBlueprint* Blueprint, FName HostWidgetName, FName SlotName) +{ + UWidget* Host = MCPFindWidget(Blueprint, HostWidgetName); + INamedSlotInterface* NamedSlots = Cast(Host); + if (!Blueprint || !NamedSlots || SlotName.IsNone()) + { + return false; + } + Host->Modify(); + NamedSlots->SetContentForSlot(SlotName, nullptr); + MarkWidgetBPDirty(Blueprint); + return true; +} + +bool UMCPGraphLibrary::SetDesiredFocusWidget(UWidgetBlueprint* Blueprint, FName WidgetName) +{ + if (!Blueprint || WidgetName.IsNone() || !MCPFindWidget(Blueprint, WidgetName)) + { + return false; + } + FWidgetBlueprintEditorUtils::SetDesiredFocus(Blueprint, WidgetName); + MarkWidgetBPDirty(Blueprint, false); + return true; +} + +bool UMCPGraphLibrary::CreateWidgetFloatAnimation(UWidgetBlueprint* Blueprint, FName AnimName, FName WidgetName, FName PropertyName, const TArray& Times, const TArray& Values) +{ + if (!Blueprint || AnimName.IsNone() || WidgetName.IsNone() || PropertyName.IsNone()) + return false; + if (Times.Num() == 0 || Times.Num() != Values.Num()) + return false; + + UWidget* Target = MCPFindWidget(Blueprint, WidgetName); + if (!Target) + return false; + + // No duplicates. + for (UWidgetAnimation* Existing : Blueprint->Animations) + if (Existing && Existing->GetFName() == AnimName) + return false; + + UWidgetAnimation* Anim = NewObject(Blueprint, AnimName, RF_Transactional); + Anim->MovieScene = NewObject(Anim, AnimName, RF_Transactional); + + // Millisecond tick resolution keeps second->frame conversion exact for our keys. + Anim->MovieScene->SetTickResolutionDirectly(FFrameRate(1000, 1)); + Anim->MovieScene->SetDisplayRate(FFrameRate(60, 1)); + + // Possess the target widget and register the runtime binding. + const FGuid BindingGuid = Anim->MovieScene->AddPossessable(WidgetName.ToString(), Target->GetClass()); + + FWidgetAnimationBinding NewBinding; + NewBinding.WidgetName = WidgetName; + NewBinding.AnimationGuid = BindingGuid; + NewBinding.bIsRootWidget = false; + Anim->AnimationBindings.Add(NewBinding); + + // Float track on the requested property (e.g. RenderOpacity). + UMovieSceneFloatTrack* Track = Anim->MovieScene->AddTrack(BindingGuid); + if (!Track) + return false; + Track->SetPropertyNameAndPath(PropertyName, PropertyName.ToString()); + + UMovieSceneFloatSection* Section = Cast(Track->CreateNewSection()); + if (!Section) + return false; + Track->AddSection(*Section); + + TArrayView Channels = Section->GetChannelProxy().GetChannels(); + if (Channels.Num() == 0) + return false; + FMovieSceneFloatChannel* Channel = Channels[0]; + + int32 LastFrame = 0; + for (int32 i = 0; i < Times.Num(); ++i) + { + const int32 FrameMs = FMath::RoundToInt(Times[i] * 1000.0f); + Channel->AddLinearKey(FFrameNumber(FrameMs), Values[i]); + LastFrame = FMath::Max(LastFrame, FrameMs); + } + + const TRange PlaybackRange(FFrameNumber(0), FFrameNumber(LastFrame)); + Section->SetRange(PlaybackRange); + Anim->MovieScene->SetPlaybackRange(PlaybackRange, /*bAlwaysMarkDirty*/ false); + + Blueprint->Animations.Add(Anim); + MarkWidgetBPDirty(Blueprint, /*bStructural*/ true); + return true; +} + +// =========================================================================== +// Compile diagnostics + graph dump +// =========================================================================== +TArray UMCPGraphLibrary::CompileWithMessages(UBlueprint* Blueprint) +{ + TArray Out; + if (!Blueprint) return Out; + FCompilerResultsLog Results; + Results.SetSourcePath(Blueprint->GetPathName()); + Results.BeginEvent(TEXT("MCPCompile")); + FKismetEditorUtilities::CompileBlueprint(Blueprint, EBlueprintCompileOptions::None, &Results); + Results.EndEvent(); + + auto AppendList = [&Out](const TArray>& Msgs, const TCHAR* Sev) + { + for (const TSharedRef& M : Msgs) + { + FString NodeName; + for (const TSharedRef& Tk : M->GetMessageTokens()) + { + if (Tk->GetType() == EMessageToken::Object) + { + TSharedRef Obj = StaticCastSharedRef(Tk); + if (UObject* Resolved = Obj->GetObject().Get()) + { + NodeName = Resolved->GetName(); + break; + } + } + } + Out.Add(FString::Printf(TEXT("%s|%s|%s"), Sev, *NodeName, *M->ToText().ToString())); + } + }; + AppendList(Results.Messages, TEXT("INFO")); + return Out; +} + +static TSharedRef NodeToJson(UEdGraphNode* N) +{ + TSharedRef J = MakeShared(); + J->SetStringField(TEXT("guid"), N->NodeGuid.ToString()); + J->SetStringField(TEXT("class"), N->GetClass()->GetName()); + J->SetNumberField(TEXT("x"), N->NodePosX); + J->SetNumberField(TEXT("y"), N->NodePosY); + J->SetStringField(TEXT("title"), N->GetNodeTitle(ENodeTitleType::ListView).ToString()); + if (!N->NodeComment.IsEmpty()) J->SetStringField(TEXT("comment"), N->NodeComment); + if (UEdGraphNode_Comment* CN = Cast(N)) + { + J->SetNumberField(TEXT("w"), CN->NodeWidth); + J->SetNumberField(TEXT("h"), CN->NodeHeight); + if (!CN->NodeComment.IsEmpty()) J->SetStringField(TEXT("comment"), CN->NodeComment); + } + + // Type-specific target hint + FString Target; + if (UK2Node_CallFunction* CF = Cast(N)) + { + const FMemberReference& Ref = CF->FunctionReference; + UClass* OwnerCls = Ref.GetMemberParentClass(); + Target = FString::Printf(TEXT("%s.%s"), OwnerCls ? *OwnerCls->GetName() : TEXT("?"), *Ref.GetMemberName().ToString()); + } + else if (UK2Node_Event* EV = Cast(N)) + { + Target = EV->EventReference.GetMemberName().ToString(); + } + else if (UK2Node_VariableGet* VG = Cast(N)) + { + Target = VG->VariableReference.GetMemberName().ToString(); + } + else if (UK2Node_VariableSet* VS = Cast(N)) + { + Target = VS->VariableReference.GetMemberName().ToString(); + } + if (!Target.IsEmpty()) J->SetStringField(TEXT("target"), Target); + + TArray> Pins; + for (UEdGraphPin* P : N->Pins) + { + if (!P) continue; + TSharedRef PJ = MakeShared(); + PJ->SetStringField(TEXT("name"), P->PinName.ToString()); + PJ->SetStringField(TEXT("dir"), P->Direction == EGPD_Input ? TEXT("in") : TEXT("out")); + PJ->SetStringField(TEXT("type"), P->PinType.PinCategory.ToString()); + if (!P->PinType.PinSubCategory.IsNone()) + PJ->SetStringField(TEXT("subtype"), P->PinType.PinSubCategory.ToString()); + if (P->PinType.PinSubCategoryObject.IsValid()) + PJ->SetStringField(TEXT("subobj"), P->PinType.PinSubCategoryObject->GetPathName()); + if (P->PinType.IsArray()) PJ->SetBoolField(TEXT("array"), true); + if (P->PinType.IsSet()) PJ->SetBoolField(TEXT("set"), true); + if (P->PinType.IsMap()) PJ->SetBoolField(TEXT("map"), true); + if (P->PinType.bIsReference) PJ->SetBoolField(TEXT("byref"), true); + if (!P->DefaultValue.IsEmpty()) PJ->SetStringField(TEXT("default"), P->DefaultValue); + if (P->DefaultObject) PJ->SetStringField(TEXT("default_obj"), P->DefaultObject->GetPathName()); + TArray> Links; + for (UEdGraphPin* L : P->LinkedTo) + { + if (!L || !L->GetOwningNode()) continue; + TSharedRef LJ = MakeShared(); + LJ->SetStringField(TEXT("node"), L->GetOwningNode()->NodeGuid.ToString()); + LJ->SetStringField(TEXT("pin"), L->PinName.ToString()); + Links.Add(MakeShared(LJ)); + } + if (Links.Num()) PJ->SetArrayField(TEXT("links"), Links); + Pins.Add(MakeShared(PJ)); + } + J->SetArrayField(TEXT("pins"), Pins); + return J; +} + +FString UMCPGraphLibrary::DumpGraphToJson(UEdGraph* Graph) +{ + if (!Graph) return TEXT("{\"error\":\"null graph\"}"); + TSharedRef Root = MakeShared(); + Root->SetStringField(TEXT("graph_name"), Graph->GetName()); + TArray> Nodes; + for (UEdGraphNode* N : Graph->Nodes) + { + if (!N) continue; + Nodes.Add(MakeShared(NodeToJson(N))); + } + Root->SetArrayField(TEXT("nodes"), Nodes); + + FString Out; + TSharedRef> Writer = TJsonWriterFactory<>::Create(&Out); + FJsonSerializer::Serialize(Root, Writer); + return Out; +} + +// =========================================================================== +// Discovery v0.3 +// =========================================================================== +static FString JsonStringify(TSharedRef Obj) +{ + FString Out; + TSharedRef> W = TJsonWriterFactory<>::Create(&Out); + FJsonSerializer::Serialize(Obj, W); + return Out; +} + +FString UMCPGraphLibrary::ListGraphsJson(UBlueprint* Blueprint) +{ + TSharedRef Root = MakeShared(); + if (!Blueprint) { Root->SetStringField(TEXT("error"), TEXT("null bp")); return JsonStringify(Root); } + auto AsList = [](const TArray& Gs) + { + TArray> A; + for (UEdGraph* G : Gs) if (G) A.Add(MakeShared(G->GetName())); + return A; + }; + Root->SetArrayField(TEXT("event"), AsList(Blueprint->UbergraphPages)); + Root->SetArrayField(TEXT("functions"), AsList(Blueprint->FunctionGraphs)); + Root->SetArrayField(TEXT("macros"), AsList(Blueprint->MacroGraphs)); + Root->SetArrayField(TEXT("delegates"), AsList(Blueprint->DelegateSignatureGraphs)); + return JsonStringify(Root); +} + +FString UMCPGraphLibrary::DescribeBlueprintJson(UBlueprint* Blueprint) +{ + TSharedRef Root = MakeShared(); + if (!Blueprint) { Root->SetStringField(TEXT("error"), TEXT("null bp")); return JsonStringify(Root); } + Root->SetStringField(TEXT("path"), Blueprint->GetPathName()); + Root->SetStringField(TEXT("parent"), Blueprint->ParentClass ? Blueprint->ParentClass->GetPathName() : TEXT("?")); + + // Interfaces + TArray> Interfaces; + for (const FBPInterfaceDescription& I : Blueprint->ImplementedInterfaces) + { + if (I.Interface) Interfaces.Add(MakeShared(I.Interface->GetPathName())); + } + Root->SetArrayField(TEXT("interfaces"), Interfaces); + + // Variables + TArray> Vars; + for (const FBPVariableDescription& V : Blueprint->NewVariables) + { + TSharedRef VJ = MakeShared(); + VJ->SetStringField(TEXT("name"), V.VarName.ToString()); + VJ->SetStringField(TEXT("type"), V.VarType.PinCategory.ToString()); + if (V.VarType.PinSubCategoryObject.IsValid()) + VJ->SetStringField(TEXT("subobj"), V.VarType.PinSubCategoryObject->GetPathName()); + VJ->SetBoolField(TEXT("instance_editable"), !(V.PropertyFlags & CPF_DisableEditOnInstance)); + VJ->SetBoolField(TEXT("expose_on_spawn"), !!(V.PropertyFlags & CPF_ExposeOnSpawn)); + if (!V.DefaultValue.IsEmpty()) VJ->SetStringField(TEXT("default"), V.DefaultValue); + if (!V.Category.IsEmpty()) VJ->SetStringField(TEXT("category"), V.Category.ToString()); + Vars.Add(MakeShared(VJ)); + } + Root->SetArrayField(TEXT("variables"), Vars); + + // Components (SCS) + TArray> Comps; + if (USimpleConstructionScript* SCS = Blueprint->SimpleConstructionScript) + { + for (USCS_Node* SN : SCS->GetAllNodes()) + { + if (!SN) continue; + TSharedRef CJ = MakeShared(); + CJ->SetStringField(TEXT("name"), SN->GetVariableName().ToString()); + if (SN->ComponentTemplate) + CJ->SetStringField(TEXT("class"), SN->ComponentTemplate->GetClass()->GetPathName()); + if (!SN->AttachToName.IsNone()) CJ->SetStringField(TEXT("socket"), SN->AttachToName.ToString()); + USCS_Node* P = nullptr; + for (USCS_Node* M : SCS->GetAllNodes()) + if (M && M->GetChildNodes().Contains(SN)) { P = M; break; } + if (P) CJ->SetStringField(TEXT("parent"), P->GetVariableName().ToString()); + Comps.Add(MakeShared(CJ)); + } + } + Root->SetArrayField(TEXT("components"), Comps); + + // Functions + TArray> Fns; + for (UEdGraph* G : Blueprint->FunctionGraphs) + if (G) Fns.Add(MakeShared(G->GetName())); + Root->SetArrayField(TEXT("functions"), Fns); + + // Dispatchers (multicast delegate props on generated class) + TArray> Disps; + if (UClass* GC = Blueprint->GeneratedClass) + { + for (TFieldIterator It(GC, EFieldIteratorFlags::ExcludeSuper); It; ++It) + { + FMulticastDelegateProperty* D = *It; + if (D) Disps.Add(MakeShared(D->GetName())); + } + } + Root->SetArrayField(TEXT("dispatchers"), Disps); + + return JsonStringify(Root); +} + +FString UMCPGraphLibrary::GetSelectedNodesJson(UBlueprint* Blueprint) +{ + TSharedRef Root = MakeShared(); + if (!Blueprint || !GEditor) { Root->SetStringField(TEXT("error"), TEXT("no editor or bp")); return JsonStringify(Root); } + UAssetEditorSubsystem* AES = GEditor->GetEditorSubsystem(); + IAssetEditorInstance* Inst = AES ? AES->FindEditorForAsset(Blueprint, /*bFocusIfOpen*/ false) : nullptr; + if (!Inst) { Root->SetStringField(TEXT("error"), TEXT("blueprint not open in editor")); return JsonStringify(Root); } + // BlueprintEditor inherits from FAssetEditorToolkit which inherits from IAssetEditorInstance. + // Cast guarded by editor-name check. + if (Inst->GetEditorName() != FName(TEXT("BlueprintEditor"))) + { + Root->SetStringField(TEXT("error"), TEXT("active editor is not BlueprintEditor")); + return JsonStringify(Root); + } + FBlueprintEditor* BPE = static_cast(Inst); + FGraphPanelSelectionSet Sel = BPE->GetSelectedNodes(); + + TArray> Arr; + for (UObject* O : Sel) + { + if (UEdGraphNode* N = Cast(O)) + { + Arr.Add(MakeShared(NodeToJson(N))); + } + } + Root->SetArrayField(TEXT("selected"), Arr); + + // Active graph + if (UEdGraph* Focused = BPE->GetFocusedGraph()) + { + Root->SetStringField(TEXT("focused_graph"), Focused->GetName()); + } + return JsonStringify(Root); +} + +TArray UMCPGraphLibrary::ListOpenBlueprints() +{ + TArray Out; + if (!GEditor) return Out; + UAssetEditorSubsystem* AES = GEditor->GetEditorSubsystem(); + if (!AES) return Out; + TArray Assets = AES->GetAllEditedAssets(); + for (UObject* A : Assets) + if (UBlueprint* BP = Cast(A)) + Out.Add(BP->GetPathName()); + return Out; +} + +FString UMCPGraphLibrary::ResolveFunctionSourceJson(UEdGraph* Graph, const FString& NodeId) +{ + TSharedRef R = MakeShared(); + UEdGraphNode* N = FindNodeByGuid(Graph, NodeId); + if (!N) { R->SetStringField(TEXT("error"), TEXT("node not found")); return JsonStringify(R); } + UK2Node_CallFunction* CF = Cast(N); + if (!CF) { R->SetStringField(TEXT("error"), TEXT("not a CallFunction node")); return JsonStringify(R); } + UClass* Owner = CF->FunctionReference.GetMemberParentClass(); + FName Fn = CF->FunctionReference.GetMemberName(); + R->SetStringField(TEXT("function"), Fn.ToString()); + R->SetStringField(TEXT("owner_class"), Owner ? Owner->GetPathName() : TEXT("?")); + R->SetBoolField(TEXT("native"), Owner && Owner->HasAllClassFlags(CLASS_Native)); + if (Owner && !Owner->HasAllClassFlags(CLASS_Native)) + { + if (UBlueprint* SrcBP = Cast(Owner->ClassGeneratedBy)) + { + R->SetStringField(TEXT("source_bp_path"), SrcBP->GetPathName()); + UEdGraph* FG = nullptr; + for (UEdGraph* G : SrcBP->FunctionGraphs) + if (G && G->GetFName() == Fn) { FG = G; break; } + if (FG) R->SetStringField(TEXT("source_graph"), FG->GetName()); + } + } + return JsonStringify(R); +} + +static void CollectRefsToVariable(UBlueprint* BP, FName Name, TArray>& Out) +{ + TArray AllGraphs; + BP->GetAllGraphs(AllGraphs); + for (UEdGraph* G : AllGraphs) + { + if (!G) continue; + for (UEdGraphNode* N : G->Nodes) + { + UK2Node_VariableGet* VG = Cast(N); + UK2Node_VariableSet* VS = Cast(N); + FName MemberName = NAME_None; + if (VG) MemberName = VG->VariableReference.GetMemberName(); + else if (VS) MemberName = VS->VariableReference.GetMemberName(); + if (MemberName == Name) + { + TSharedRef J = MakeShared(); + J->SetStringField(TEXT("graph"), G->GetName()); + J->SetStringField(TEXT("guid"), N->NodeGuid.ToString()); + J->SetStringField(TEXT("kind"), VG ? TEXT("get") : TEXT("set")); + Out.Add(MakeShared(J)); + } + } + } +} + +FString UMCPGraphLibrary::FindVariableReferencesJson(UBlueprint* Blueprint, FName VariableName) +{ + TSharedRef R = MakeShared(); + if (!Blueprint) { R->SetStringField(TEXT("error"), TEXT("null bp")); return JsonStringify(R); } + TArray> Refs; + CollectRefsToVariable(Blueprint, VariableName, Refs); + R->SetArrayField(TEXT("references"), Refs); + R->SetNumberField(TEXT("count"), Refs.Num()); + return JsonStringify(R); +} + +FString UMCPGraphLibrary::FindFunctionReferencesJson(UBlueprint* Blueprint, FName FunctionName) +{ + TSharedRef R = MakeShared(); + if (!Blueprint) { R->SetStringField(TEXT("error"), TEXT("null bp")); return JsonStringify(R); } + TArray AllGraphs; + Blueprint->GetAllGraphs(AllGraphs); + TArray> Refs; + for (UEdGraph* G : AllGraphs) + { + if (!G) continue; + for (UEdGraphNode* N : G->Nodes) + { + UK2Node_CallFunction* CF = Cast(N); + if (!CF) continue; + if (CF->FunctionReference.GetMemberName() == FunctionName) + { + TSharedRef J = MakeShared(); + J->SetStringField(TEXT("graph"), G->GetName()); + J->SetStringField(TEXT("guid"), N->NodeGuid.ToString()); + UClass* O = CF->FunctionReference.GetMemberParentClass(); + J->SetStringField(TEXT("owner"), O ? O->GetPathName() : TEXT("?")); + Refs.Add(MakeShared(J)); + } + } + } + R->SetArrayField(TEXT("references"), Refs); + R->SetNumberField(TEXT("count"), Refs.Num()); + return JsonStringify(R); +} + +TArray UMCPGraphLibrary::FindBlueprintsByParent(const FString& ParentClassPath) +{ + TArray Out; + IAssetRegistry& AR = FModuleManager::LoadModuleChecked(TEXT("AssetRegistry")).Get(); + FARFilter Filter; + Filter.ClassPaths.Add(FTopLevelAssetPath(TEXT("/Script/Engine"), TEXT("Blueprint"))); + Filter.bRecursiveClasses = true; + TArray Assets; + AR.GetAssets(Filter, Assets); + for (const FAssetData& AD : Assets) + { + const FString Parent = AD.GetTagValueRef(FBlueprintTags::ParentClassPath); + if (Parent == ParentClassPath || Parent.Contains(ParentClassPath)) + Out.Add(AD.GetSoftObjectPath().ToString()); + } + return Out; +} + +TArray UMCPGraphLibrary::GetReferencedAssets(const FString& AssetPath) +{ + TArray Out; + IAssetRegistry& AR = FModuleManager::LoadModuleChecked(TEXT("AssetRegistry")).Get(); + TArray Deps; + AR.GetDependencies(FName(*FPackageName::ObjectPathToPackageName(AssetPath)), Deps); + for (FName D : Deps) Out.Add(D.ToString()); + return Out; +} + +TArray UMCPGraphLibrary::GetReferencersOfAsset(const FString& AssetPath) +{ + TArray Out; + IAssetRegistry& AR = FModuleManager::LoadModuleChecked(TEXT("AssetRegistry")).Get(); + TArray Refs; + AR.GetReferencers(FName(*FPackageName::ObjectPathToPackageName(AssetPath)), Refs); + for (FName R : Refs) Out.Add(R.ToString()); + return Out; +} + +// =========================================================================== +// Editing v0.3 +// =========================================================================== +int32 UMCPGraphLibrary::MoveNodes(UEdGraph* Graph, const TArray& NodeIds, const TArray& Positions) +{ + if (!Graph || NodeIds.Num() != Positions.Num()) return 0; + int32 Moved = 0; + for (int32 i = 0; i < NodeIds.Num(); ++i) + { + if (UEdGraphNode* N = FindNodeByGuid(Graph, NodeIds[i])) + { + N->NodePosX = static_cast(Positions[i].X); + N->NodePosY = static_cast(Positions[i].Y); + ++Moved; + } + } + if (Moved && Graph) + if (UBlueprint* BP = FBlueprintEditorUtils::FindBlueprintForGraph(Graph)) MarkBPDirty(BP); + return Moved; +} + +bool UMCPGraphLibrary::ResizeCommentNode(UEdGraph* Graph, const FString& NodeId, FVector2D Size) +{ + UEdGraphNode* N = FindNodeByGuid(Graph, NodeId); + UEdGraphNode_Comment* CN = Cast(N); + if (!CN) return false; + CN->NodeWidth = FMath::Max(64, (int32)Size.X); + CN->NodeHeight = FMath::Max(64, (int32)Size.Y); + return true; +} + +bool UMCPGraphLibrary::SetNodeComment(UEdGraph* Graph, const FString& NodeId, const FString& Comment, bool bCommentBubbleVisible) +{ + UEdGraphNode* N = FindNodeByGuid(Graph, NodeId); + if (!N) return false; + N->NodeComment = Comment; + N->bCommentBubbleVisible = bCommentBubbleVisible; + return true; +} + +// =========================================================================== +// Navigation v0.3 +// =========================================================================== +bool UMCPGraphLibrary::OpenBlueprintEditor(UBlueprint* Blueprint) +{ + if (!Blueprint || !GEditor) return false; + UAssetEditorSubsystem* AES = GEditor->GetEditorSubsystem(); + return AES && AES->OpenEditorForAsset(Blueprint); +} + +bool UMCPGraphLibrary::OpenGraphInEditor(UBlueprint* Blueprint, UEdGraph* Graph) +{ + if (!OpenBlueprintEditor(Blueprint) || !Graph) return false; + UAssetEditorSubsystem* AES = GEditor->GetEditorSubsystem(); + IAssetEditorInstance* Inst = AES->FindEditorForAsset(Blueprint, true); + if (!Inst || Inst->GetEditorName() != FName(TEXT("BlueprintEditor"))) return false; + FBlueprintEditor* BPE = static_cast(Inst); + BPE->OpenDocument(Graph, FDocumentTracker::OpenNewDocument); + return true; +} + +bool UMCPGraphLibrary::JumpToNodeInEditor(UBlueprint* Blueprint, UEdGraph* Graph, const FString& NodeId) +{ + if (!OpenGraphInEditor(Blueprint, Graph)) return false; + UEdGraphNode* N = FindNodeByGuid(Graph, NodeId); + if (!N) return false; + UAssetEditorSubsystem* AES = GEditor->GetEditorSubsystem(); + IAssetEditorInstance* Inst = AES->FindEditorForAsset(Blueprint, true); + FBlueprintEditor* BPE = static_cast(Inst); + BPE->JumpToNode(N, /*bRequestRename*/ false); + return true; +} diff --git a/Source/UEBlueprintMCPEditor/Private/MCPMaterialLibrary.cpp b/Source/UEBlueprintMCPEditor/Private/MCPMaterialLibrary.cpp new file mode 100644 index 0000000..e9a4d38 --- /dev/null +++ b/Source/UEBlueprintMCPEditor/Private/MCPMaterialLibrary.cpp @@ -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& Object) +{ + FString Out; + const TSharedRef> 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& 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& 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 MakeStatusJson(bool bOk, const FString& Error = FString()) +{ + TSharedRef Root = MakeShared(); + Root->SetBoolField(TEXT("ok"), bOk); + if (!Error.IsEmpty()) + { + Root->SetStringField(TEXT("error"), Error); + } + return Root; +} + +void AppendExpressionJson(UMaterialExpression* Expression, TArray>& Nodes, TArray>& Edges) +{ + if (!Expression) + { + return; + } + + TSharedRef Node = MakeShared(); + 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> 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 InputJson = MakeShared(); + InputJson->SetStringField(TEXT("name"), InputName.ToString()); + InputJson->SetNumberField(TEXT("index"), InputIndex); + InputJson->SetBoolField(TEXT("connected"), Input && Input->Expression); + Inputs.Add(MakeShared(InputJson)); + + if (Input && Input->Expression) + { + TSharedRef Edge = MakeShared(); + TArray> From; + From.Add(MakeShared(ExpressionId(Input->Expression))); + From.Add(MakeShared(OutputNameForIndex(Input->Expression, Input->OutputIndex))); + TArray> To; + To.Add(MakeShared(ExpressionId(Expression))); + To.Add(MakeShared(InputName.ToString())); + Edge->SetArrayField(TEXT("from"), From); + Edge->SetArrayField(TEXT("to"), To); + Edges.Add(MakeShared(Edge)); + } + } + Node->SetArrayField(TEXT("inputs"), Inputs); + + TArray> OutputsJson; + TArray& Outputs = Expression->GetOutputs(); + for (int32 OutputIndex = 0; OutputIndex < Outputs.Num(); ++OutputIndex) + { + TSharedRef OutputJson = MakeShared(); + OutputJson->SetStringField(TEXT("name"), Outputs[OutputIndex].OutputName.ToString()); + OutputJson->SetNumberField(TEXT("index"), OutputIndex); + OutputsJson.Add(MakeShared(OutputJson)); + } + Node->SetArrayField(TEXT("outputs"), OutputsJson); + + Nodes.Add(MakeShared(Node)); +} +} + +TArray UMCPMaterialLibrary::GetMaterialExpressions(UObject* MaterialOrFunction) +{ + TArray Out; + if (UMaterial* Material = Cast(MaterialOrFunction)) + { + for (TObjectPtr Expression : Material->GetExpressionCollection().Expressions) + { + if (Expression) + { + Out.Add(Expression.Get()); + } + } + } + else if (UMaterialFunction* Function = Cast(MaterialOrFunction)) + { + for (TObjectPtr 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 Root = MakeShared(); + 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> Nodes; + TArray> Edges; + for (UMaterialExpression* Expression : GetMaterialExpressions(MaterialOrFunction)) + { + AppendExpressionJson(Expression, Nodes, Edges); + } + Root->SetArrayField(TEXT("nodes"), Nodes); + Root->SetArrayField(TEXT("edges"), Edges); + + if (UMaterial* Material = Cast(MaterialOrFunction)) + { + TSharedRef Outputs = MakeShared(); + for (int32 PropertyIndex = 0; PropertyIndex < static_cast(MP_MAX); ++PropertyIndex) + { + const EMaterialProperty Property = static_cast(PropertyIndex); + FExpressionInput* Input = Material->GetExpressionInputForProperty(Property); + if (!Input || !Input->Expression) + { + continue; + } + + TSharedRef Ref = MakeShared(); + TArray> From; + From.Add(MakeShared(ExpressionId(Input->Expression))); + From.Add(MakeShared(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(MaterialOrFunction)) + { + Material->PreEditChange(nullptr); + Material->PostEditChange(); + Material->MarkPackageDirty(); + } + else if (UMaterialFunction* Function = Cast(MaterialOrFunction)) + { + Function->PreEditChange(nullptr); + Function->PostEditChange(); + Function->MarkPackageDirty(); + } + + TSharedRef 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); +} diff --git a/Source/UEBlueprintMCPEditor/Private/MCPNiagaraLibrary.cpp b/Source/UEBlueprintMCPEditor/Private/MCPNiagaraLibrary.cpp new file mode 100644 index 0000000..5ff9d7c --- /dev/null +++ b/Source/UEBlueprintMCPEditor/Private/MCPNiagaraLibrary.cpp @@ -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(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& Out) +{ + if (!OutputNode) return; + UEdGraph* Graph = OutputNode->GetGraph(); + if (!Graph) return; + for (UEdGraphNode* Node : Graph->Nodes) + { + if (UNiagaraNodeFunctionCall* FC = Cast(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& 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("AssetTools").Get(); + UNiagaraSystemFactoryNew* Factory = NewObject(); + UObject* Created = AssetTools.CreateAsset(AssetName, PackageName, UNiagaraSystem::StaticClass(), Factory); + return Created != nullptr; +} + +TArray UMCPNiagaraLibrary::GetEmitterHandleNames(UNiagaraSystem* System) +{ + TArray 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 UMCPNiagaraLibrary::ListModules(UNiagaraSystem* System, const FString& EmitterName, const FString& GroupName) +{ + TArray Out; + const FStackLocation Loc = LocateStack(System, EmitterName, GroupName); + if (!Loc.bValid()) return Out; + + TArray 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 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(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 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); +} diff --git a/Source/UEBlueprintMCPEditor/Private/MCPVoxelGraphLibrary.cpp b/Source/UEBlueprintMCPEditor/Private/MCPVoxelGraphLibrary.cpp new file mode 100644 index 0000000..3898692 --- /dev/null +++ b/Source/UEBlueprintMCPEditor/Private/MCPVoxelGraphLibrary.cpp @@ -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& Object) +{ + FString Out; + const TSharedRef> Writer = TJsonWriterFactory<>::Create(&Out); + FJsonSerializer::Serialize(Object, Writer); + return Out; +} + +static UVoxelGraph* AsVoxelGraph(UObject* Asset) +{ + return Cast(Asset); +} + +static void FillTerminalJson(TSharedRef 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 VoxelNodeToJson(UEdGraphNode* Node) +{ + TSharedRef Json = MakeShared(); + 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(Node)) + { + Json->SetNumberField(TEXT("w"), CommentNode->NodeWidth); + Json->SetNumberField(TEXT("h"), CommentNode->NodeHeight); + } + + TArray> Pins; + for (UEdGraphPin* Pin : Node->Pins) + { + if (!Pin) + { + continue; + } + + TSharedRef PinJson = MakeShared(); + 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> Links; + for (UEdGraphPin* LinkedPin : Pin->LinkedTo) + { + if (!LinkedPin || !LinkedPin->GetOwningNode()) + { + continue; + } + + TSharedRef LinkJson = MakeShared(); + LinkJson->SetStringField(TEXT("node"), LinkedPin->GetOwningNode()->NodeGuid.ToString()); + LinkJson->SetStringField(TEXT("pin"), LinkedPin->PinName.ToString()); + Links.Add(MakeShared(LinkJson)); + } + if (Links.Num() > 0) + { + PinJson->SetArrayField(TEXT("links"), Links); + } + Pins.Add(MakeShared(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>& GetVoxelNodeCatalogue() +{ + static TArray> 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()) + { + if (!Struct) + { + continue; + } + if (Struct->HasMetaData(TEXT("Abstract")) || Struct->HasMetaData(TEXT("Internal"))) + { + continue; + } + Nodes.Add(MakeSharedStruct(Struct)); + } + + // Function-library nodes (UVoxelFunctionLibrary UFUNCTIONs wrapped as FVoxelNode_UFunction): + // most math / position / curve nodes live here. + for (const TSubclassOf& Class : GetDerivedClasses()) + { + 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(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 FindCatalogueNode(const FString& NodeId) +{ + for (const TSharedRef& 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(); + if (T == TEXT("int") || T == TEXT("int32")) return FVoxelPinType::Make(); + if (T == TEXT("bool")) return FVoxelPinType::Make(); + if (T == TEXT("vector2d") || T == TEXT("vector2"))return FVoxelPinType::Make(); + if (T == TEXT("vector") || T == TEXT("vector3d")) return FVoxelPinType::Make(); + if (T == TEXT("color") || T == TEXT("linearcolor"))return FVoxelPinType::Make(); + if (T == TEXT("name")) return FVoxelPinType::Make(); + return FVoxelPinType::Make(); +} + +// 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(nullptr, ClassPath); + if (!Graph || !NodeClass) + { + return nullptr; + } + + Graph->Modify(); + UEdGraphNode* Node = NewObject(Graph, NodeClass, NAME_None, RF_Transactional); + if (!Node) + { + return nullptr; + } + + if (FStructProperty* GuidProp = FindFProperty(NodeClass, TEXT("Guid"))) + { + *GuidProp->ContainerPtrToValuePtr(Node) = Guid; + } + + Node->NodePosX = static_cast(Position.X); + Node->NodePosY = static_cast(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() : 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 JsonError(const FString& Message) +{ + TSharedRef Root = MakeShared(); + 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 Root = MakeShared(); + 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> Terminals; + Graph->ForeachTerminalGraph_NoInheritance([&](UVoxelTerminalGraph& TerminalGraph) + { + TSharedRef Item = MakeShared(); + FillTerminalJson(Item, TerminalGraph); + Terminals.Add(MakeShared(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 Root = MakeShared(); + 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 TerminalJson = MakeShared(); + FillTerminalJson(TerminalJson, *TerminalGraph); + Root->SetObjectField(TEXT("terminal"), TerminalJson); + + TArray> Nodes; + UEdGraph& EdGraph = TerminalGraph->GetEdGraph(); + for (UEdGraphNode* Node : EdGraph.Nodes) + { + if (Node) + { + Nodes.Add(MakeShared(VoxelNodeToJson(Node))); + } + } + Root->SetArrayField(TEXT("nodes"), Nodes); + return VoxelJsonStringify(Root); +} + +FString UMCPVoxelGraphLibrary::GetSelectedNodesJson(UObject* Asset, const FString& Terminal) +{ + TSharedRef Root = MakeShared(); + 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 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> Nodes; + for (UEdGraphNode* Node : Toolkit->GetSelectedNodes()) + { + if (Node) + { + Nodes.Add(MakeShared(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(Graph); + Node->CreateNewGuid(); + Node->NodePosX = static_cast(Position.X); + Node->NodePosY = static_cast(Position.Y); + Node->NodeWidth = FMath::Max(64, static_cast(Size.X)); + Node->NodeHeight = FMath::Max(64, static_cast(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 Root = MakeShared(); + Root->SetBoolField(TEXT("ok"), true); + + const FString FilterLower = Filter.ToLower(); + const int32 EffectiveLimit = Limit > 0 ? Limit : MAX_int32; + + TArray> Items; + int32 Total = 0; + for (const TSharedRef& 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 Item = MakeShared(); + 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(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 Chosen = FindCatalogueNode(NodeId); + if (!Chosen) + { + return FString(); + } + + UClass* NodeClass = FindObject(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( + NewObject(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(Position.X); + Node->NodePosY = static_cast(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 Root = MakeShared(); + 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> Items; + + const auto AddItem = [&Items](const FGuid& Guid, const FName& PropName, const FVoxelPinType& Type, const FString& Category) + { + TSharedRef Item = MakeShared(); + 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(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 Root = MakeShared(); + 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(); + 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(); + 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 diff --git a/Source/UEBlueprintMCPEditor/Private/MCPWidgetRenderLibrary.cpp b/Source/UEBlueprintMCPEditor/Private/MCPWidgetRenderLibrary.cpp new file mode 100644 index 0000000..87519e1 --- /dev/null +++ b/Source/UEBlueprintMCPEditor/Private/MCPWidgetRenderLibrary.cpp @@ -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 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(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(); + 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 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(FName("ImageWrapper")); + TSharedPtr 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& 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; +} diff --git a/Source/UEBlueprintMCPEditor/Private/UEBlueprintMCPEditor.cpp b/Source/UEBlueprintMCPEditor/Private/UEBlueprintMCPEditor.cpp new file mode 100644 index 0000000..2a4ffbe --- /dev/null +++ b/Source/UEBlueprintMCPEditor/Private/UEBlueprintMCPEditor.cpp @@ -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(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 Cursor) override + { + } + + virtual bool HandleMouseButtonDownEvent(FSlateApplication& SlateApp, const FPointerEvent& MouseEvent) override + { + if (!Owner.bMCPZoneModeActive || MouseEvent.GetEffectingButton() != EKeys::LeftMouseButton) + { + return false; + } + + TSharedPtr Editor = Owner.ActiveMCPZoneEditor.Pin(); + if (!Editor.IsValid() || !Editor->GetFocusedGraph()) + { + return false; + } + + TSharedPtr 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 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 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(); + if (!Context || !Context->BlueprintEditor.IsValid() || !Context->GetBlueprintObj()) + { + return; + } + + TWeakPtr 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 BlueprintEditor) +{ + TSharedPtr 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 PreviousEditor = ActiveMCPZoneEditor.Pin()) + { + RemoveMCPZones(PreviousEditor->GetBlueprintObj()); + } + } + + bMCPZoneModeActive = true; + ActiveMCPZoneEditor = Editor; + if (!MCPZoneInputProcessor.IsValid() && FSlateApplication::IsInitialized()) + { + MCPZoneInputProcessor = MakeShared(*this); + FSlateApplication::Get().RegisterInputPreProcessor(MCPZoneInputProcessor); + } +} + +bool FUEBlueprintMCPEditorModule::IsMCPZoneModeActive(TWeakPtr BlueprintEditor) const +{ + return bMCPZoneModeActive && ActiveMCPZoneEditor.Pin() == BlueprintEditor.Pin(); +} + +bool FUEBlueprintMCPEditorModule::TryCreateMCPZoneFromScreenDrag(const FVector2f& StartScreenPosition, const FVector2f& EndScreenPosition) +{ + TSharedPtr Editor = ActiveMCPZoneEditor.Pin(); + if (!Editor.IsValid()) + { + return false; + } + + UBlueprint* Blueprint = Editor->GetBlueprintObj(); + UEdGraph* Graph = Editor->GetFocusedGraph(); + if (!Blueprint || !Graph) + { + return false; + } + + TSharedPtr 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(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 Graphs; + Blueprint->GetAllGraphs(Graphs); + for (UEdGraph* Graph : Graphs) + { + if (!Graph) + { + continue; + } + + Graph->Modify(); + TArray Nodes = Graph->Nodes; + for (UEdGraphNode* Node : Nodes) + { + if (IsMCPZoneComment(Node)) + { + Graph->RemoveNode(Node); + } + } + } + + FBlueprintEditorUtils::MarkBlueprintAsModified(Blueprint); +} + +#undef LOCTEXT_NAMESPACE diff --git a/Source/UEBlueprintMCPEditor/Public/MCPCaptureLibrary.h b/Source/UEBlueprintMCPEditor/Public/MCPCaptureLibrary.h new file mode 100644 index 0000000..7c58a46 --- /dev/null +++ b/Source/UEBlueprintMCPEditor/Public/MCPCaptureLibrary.h @@ -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 + ); +}; diff --git a/Source/UEBlueprintMCPEditor/Public/MCPGraphLibrary.h b/Source/UEBlueprintMCPEditor/Public/MCPGraphLibrary.h new file mode 100644 index 0000000..e460bfe --- /dev/null +++ b/Source/UEBlueprintMCPEditor/Public/MCPGraphLibrary.h @@ -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 ListNodeIds(UEdGraph* Graph); + + UFUNCTION(BlueprintCallable, Category = "MCP|Graph") + static TArray 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& Times, const TArray& Values); + + // ==================================================================== + // Compile / introspect + // ==================================================================== + UFUNCTION(BlueprintCallable, Category = "MCP|Asset") + static bool CompileAndSaveBlueprint(UBlueprint* Blueprint); + + UFUNCTION(BlueprintCallable, Category = "MCP|Asset") + static TArray GetCompileErrors(UBlueprint* Blueprint); + + /** Compile + capture FCompilerResultsLog. Returns "SEV|NodeName|Message" per entry. */ + UFUNCTION(BlueprintCallable, Category = "MCP|Asset") + static TArray 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 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 FindBlueprintsByParent(const FString& ParentClassPath); + + /** Hard-referenced assets of this BP (asset registry). */ + UFUNCTION(BlueprintCallable, Category = "MCP|Discover") + static TArray GetReferencedAssets(const FString& AssetPath); + + /** Reverse: assets that reference this BP. */ + UFUNCTION(BlueprintCallable, Category = "MCP|Discover") + static TArray 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& NodeIds, const TArray& 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 ListFunctionsOnClass(UClass* Class); + + UFUNCTION(BlueprintCallable, Category = "MCP|Introspect") + static TArray ListPropertiesOnClass(UClass* Class); + + UFUNCTION(BlueprintCallable, Category = "MCP|Introspect") + static TArray 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 + static TNode* PlaceNode(UEdGraph* Graph, FVector2D Position); +}; diff --git a/Source/UEBlueprintMCPEditor/Public/MCPMaterialLibrary.h b/Source/UEBlueprintMCPEditor/Public/MCPMaterialLibrary.h new file mode 100644 index 0000000..43729b6 --- /dev/null +++ b/Source/UEBlueprintMCPEditor/Public/MCPMaterialLibrary.h @@ -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 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); +}; diff --git a/Source/UEBlueprintMCPEditor/Public/MCPNiagaraLibrary.h b/Source/UEBlueprintMCPEditor/Public/MCPNiagaraLibrary.h new file mode 100644 index 0000000..41e2b61 --- /dev/null +++ b/Source/UEBlueprintMCPEditor/Public/MCPNiagaraLibrary.h @@ -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 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 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); +}; diff --git a/Source/UEBlueprintMCPEditor/Public/MCPVoxelGraphLibrary.h b/Source/UEBlueprintMCPEditor/Public/MCPVoxelGraphLibrary.h new file mode 100644 index 0000000..3b8cb76 --- /dev/null +++ b/Source/UEBlueprintMCPEditor/Public/MCPVoxelGraphLibrary.h @@ -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); +}; diff --git a/Source/UEBlueprintMCPEditor/Public/MCPWidgetRenderLibrary.h b/Source/UEBlueprintMCPEditor/Public/MCPWidgetRenderLibrary.h new file mode 100644 index 0000000..e16658b --- /dev/null +++ b/Source/UEBlueprintMCPEditor/Public/MCPWidgetRenderLibrary.h @@ -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 WidgetClass, + const FString& OutputAbsPath, + const FString& FileNameNoExt, + int32 Width, + int32 Height, + FString& OutFullPath, + FString& OutError + ); +}; diff --git a/Source/UEBlueprintMCPEditor/Public/UEBlueprintMCPEditor.h b/Source/UEBlueprintMCPEditor/Public/UEBlueprintMCPEditor.h new file mode 100644 index 0000000..7a9bb61 --- /dev/null +++ b/Source/UEBlueprintMCPEditor/Public/UEBlueprintMCPEditor.h @@ -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 BlueprintEditor); + bool IsMCPZoneModeActive(TWeakPtr 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 ActiveMCPZoneEditor; + TSharedPtr MCPZoneInputProcessor; +}; diff --git a/Source/UEBlueprintMCPEditor/UEBlueprintMCPEditor.Build.cs b/Source/UEBlueprintMCPEditor/UEBlueprintMCPEditor.Build.cs new file mode 100644 index 0000000..f58976d --- /dev/null +++ b/Source/UEBlueprintMCPEditor/UEBlueprintMCPEditor.Build.cs @@ -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 /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(); + if (Target.ProjectFile != null) + { + roots.Add(Path.Combine(Target.ProjectFile.Directory.FullName, "Plugins")); + } + // The Plugins folder this plugin itself lives in (covers /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; + } +} diff --git a/UEBlueprintMCP.uplugin b/UEBlueprintMCP.uplugin new file mode 100644 index 0000000..d3adb6f --- /dev/null +++ b/UEBlueprintMCP.uplugin @@ -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 + } + ] +} diff --git a/UI_WORKFLOW.md b/UI_WORKFLOW.md new file mode 100644 index 0000000..98331fb --- /dev/null +++ b/UI_WORKFLOW.md @@ -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 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 Brushes; +}; +``` + +### A.2 Instance +Create instance at `/Game/UI/Theme/DA_UITheme` (one per visual mode — fork as +`DA_UITheme_` 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_`?" + +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 `/Saved/MCP/widgets/.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_` | +| Screens | `/Game/UI/Screens/` | `WBP_` (no prefix like "Menu_" — flat) | +| Materials | `/Game/UI/Materials/` | `M_UI_*`, `MI_UI_*` | +| Icons | `/Game/UI/Icons/` | `T_UI_Icon__` | +| 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. diff --git a/agent-gateway/.gitignore b/agent-gateway/.gitignore new file mode 100644 index 0000000..0120f12 --- /dev/null +++ b/agent-gateway/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +auth.env +integrations.json +.generated-audio/ diff --git a/agent-gateway/README.md b/agent-gateway/README.md new file mode 100644 index 0000000..633d599 --- /dev/null +++ b/agent-gateway/README.md @@ -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. diff --git a/agent-gateway/auth.env.example b/agent-gateway/auth.env.example new file mode 100644 index 0000000..26f3afb --- /dev/null +++ b/agent-gateway/auth.env.example @@ -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= diff --git a/agent-gateway/package-lock.json b/agent-gateway/package-lock.json new file mode 100644 index 0000000..1ab8920 --- /dev/null +++ b/agent-gateway/package-lock.json @@ -0,0 +1,1465 @@ +{ + "name": "ue-mcp-agent-gateway", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ue-mcp-agent-gateway", + "version": "0.1.0", + "dependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.3.158" + } + }, + "node_modules/@anthropic-ai/claude-agent-sdk": { + "version": "0.3.158", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.158.tgz", + "integrity": "sha512-Rht8Ui7HBsVdBCG6SYs9b+JmJWAVoDXPD2pWNVMSFrzyAS4nizwdz3HtUnAobFumgzbT3LbpWzHdLfUDu4gM4w==", + "license": "SEE LICENSE IN README.md", + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.158", + "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.158", + "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.158", + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.158", + "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.158", + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.158", + "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.158", + "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.158" + }, + "peerDependencies": { + "@anthropic-ai/sdk": ">=0.93.0", + "@modelcontextprotocol/sdk": "^1.29.0", + "zod": "^4.0.0" + } + }, + "node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": { + "version": "0.3.158", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.158.tgz", + "integrity": "sha512-9mlkVHeHIiF7oJUjVHbieYgsTzGmKRcgUmp52BhUaDL40Gm5AC0Lotqn0ULniqlr6pNWcbA0+gjEwg7VI9VtSA==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": { + "version": "0.3.158", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.158.tgz", + "integrity": "sha512-3S4ef/f2ksTmUSEK6Di9Vch2Fm5udmZq8kVKO8mAdLV+VuG6KW9kYBzbogDtwYIZFuFo8xs1sPGP2hsdZvghhA==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": { + "version": "0.3.158", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.158.tgz", + "integrity": "sha512-ut9uJclBqrH5NhAuVc0zN84eQM3MP4DTQqh12eVUx83eekHu7l1v6Bg+N5P/m4SM4tEhKl8lQjLpliPtML4lUA==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": { + "version": "0.3.158", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.158.tgz", + "integrity": "sha512-lJ2ZKKirs/RTAU+9IYTd+3CKE4vYe694FkRZ04TBQPiq/ujRUa3vmGm6gSIdmDlrdYMX5j4rdcun+Ym6mPXTtA==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": { + "version": "0.3.158", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.158.tgz", + "integrity": "sha512-PqcDGFuzvFA0JPYa11Xcoga13oQbbAGibfASmZG5+dhoq8SniUCj0LkGGnVAgTqX4SQIIMYklS6l7egwkJIi3w==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": { + "version": "0.3.158", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.158.tgz", + "integrity": "sha512-cU0NOOA9B8I6E58HejqtO/vsYg3rfWgoaDmrJ1BzM5J4eNS3iSeaxDm7MzcyvEbTHPC1Qgj89XoiDHqhf/V/vg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": { + "version": "0.3.158", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.158.tgz", + "integrity": "sha512-47S9BUuNOYuUGaMe9ZUaRMfd1UVRt1iP9UwHWqCJUsrTPNnTCY/7lW7aecEr7Z/h3JctegTvx6Iy+mp697R1hQ==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": { + "version": "0.3.158", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.158.tgz", + "integrity": "sha512-YLjoU6Y+WN2nqnafbbEoVd+1ISaz2lHpArTnE+sNO8hOokBLEwAHOWd8uRv9c9CSMCUEyMwvIQ7ANOEXG6NsdQ==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.100.1", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.100.1.tgz", + "integrity": "sha512-RANcEe7LpiLczkKGOwoXOTuFdPhuubS0i4xaAKOMpcqc55YO0mukgxppV7eygx3DXNjxWT6RYOLPyOy0aIAmwg==", + "license": "MIT", + "peer": true, + "dependencies": { + "json-schema-to-ts": "^3.1.1", + "standardwebhooks": "^1.0.0" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT", + "peer": true + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "peer": true, + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "peer": true, + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "peer": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "peer": true, + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "peer": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "peer": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "peer": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT", + "peer": true + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "peer": true, + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT", + "peer": true + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "peer": true, + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "peer": true, + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "license": "MIT", + "peer": true, + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT", + "peer": true + }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense", + "peer": true + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "peer": true, + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "peer": true, + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "peer": true, + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.23", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz", + "integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "peer": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC", + "peer": true + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT", + "peer": true + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC", + "peer": true + }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT", + "peer": true + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause", + "peer": true + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "peer": true, + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT", + "peer": true + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "peer": true, + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "peer": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "peer": true, + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "peer": true, + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT", + "peer": true + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "peer": true, + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC", + "peer": true + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "peer": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "peer": true, + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "peer": true, + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "peer": true, + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "peer": true, + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.6" + } + }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT", + "peer": true + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "peer": true, + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "peer": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC", + "peer": true + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peer": true, + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/agent-gateway/package.json b/agent-gateway/package.json new file mode 100644 index 0000000..1892b15 --- /dev/null +++ b/agent-gateway/package.json @@ -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" + } +} diff --git a/agent-gateway/public/app.js b/agent-gateway/public/app.js new file mode 100644 index 0000000..3f5031f --- /dev/null +++ b/agent-gateway/public/app.js @@ -0,0 +1,699 @@ +// In-engine agent — "Forge" client (EventSource transport for UE CEF). +(() => { + "use strict"; + const $ = (id) => document.getElementById(id); + + const I = { + brand: '', + history: '', + plus: '', + search: '', + server: '', + mention: '', + tools: '', + clip: '', + send: '', + check: '', + x: '', + chev: '', + asset: '', + chat: '', + spark: '', + trash: '', + gear: '', + volume: '', + download: '', + play: '', + pause: '', + textfix: '', + translate: '', + }; + + // ---- 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 = `
ue-blueprintSELF-HOST${ueToolCount} tools
`; + } + // 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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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 `${I.mention}${esc(leaf)}`; + }); + seg = seg.replace(ASSET_RE, (m) => { + const path = m.replace(/\.$/, ""); const leaf = path.split("/").pop(); + return `${I.asset}${esc(leaf)}`; + }); + return seg; + } + function renderMarkdown(text) { + const parts = text.split(/```/); let html = ""; + for (let i = 0; i < parts.length; i++) { + if (i % 2 === 1) { html += `
${esc(parts[i].replace(/^[a-zA-Z0-9]*\n/, ""))}
`; } + else { + let seg = esc(parts[i]); + seg = seg.replace(/`([^`]+)`/g, (_, c) => `${c}`); + seg = seg.replace(/\*\*([^*]+)\*\*/g, "$1"); + seg = seg.split(/(.*?<\/code>)/g).map((s) => (s.startsWith("") ? s : chipsInSegment(s))).join(""); + html += seg.replace(/\n/g, "
"); + } + } + 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 `
${images.map((d) => ``).join("")}
`; + } + function addUserMessage(text, images) { + const el = document.createElement("div"); el.className = "msg user-msg"; + el.innerHTML = `
EX
Вы ${nowStr()}
` + + imagesHtml(images) + `
${renderMarkdown(text)}
`; + threadEl.appendChild(el); scrollDown(); + } + function addAgentShell() { + const el = document.createElement("div"); el.className = "msg"; + el.innerHTML = `
${I.brand}
Forge ${($("modelName").textContent)}
`; + threadEl.appendChild(el); + return { el, body: el.querySelector(".msg-body") }; + } + + function audioSetHtml(message) { + const sounds = message.sounds || []; + return `
ElevenLabs variations for: ${esc(message.prompt || "")}
` + + sounds.map((s, i) => `
${I.volume}${esc(s.name || `variation_${i + 1}`)}${Math.max(1, Math.round((s.bytes || 0) / 1024))} KB
0:000:00${I.volume}
`).join("") + + `
`; + } + 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 `${I.asset}${esc(tgt.split("/").pop())}`; + if (/^[A-Za-z0-9_]+$/.test(tgt) && tgt !== base) return `${I.asset}${esc(tgt)}`; + 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 = `
Работаю…
${I.chev}
`; + 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 = `
${turn.nStep}
${esc(verb)}${chipFor(tgt, base)}${esc(toolLabel(name))}
`; + 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 = `
результат
${esc(text.length > 6000 ? text.slice(0, 6000) + "\n…" : text)}
`; + 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 = `${I.check}`; + 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) => `
${s.ok === false ? I.x : I.check}
${esc(s.verb || "")}${chipFor(s.tgt, "")}${esc(s.tool || "")}
${esc(s.dur || "")}
`).join(""); + c.innerHTML = `
${I.check}
Готово · ${m.steps.length} ${plural(m.steps.length)}
${I.chev}
${rows}
`; + 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 += `
${g}
`; + for (const c of groups[g]) html += `
${I.chat}${esc(c.title)}${I.trash}
`; + } + $("sbScroll").innerHTML = html || `
Сессии
`; + } + + 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 = `
${I.brand}

Forge — копилот внутри движка

Я работаю с проектом через ue-blueprint MCP и могу править Blueprints, спавнить ноды, билдить C++, делать скриншоты UMG.

${sugg("Пингани UE", "проверь связь и активный Blueprint")}${sugg("Список MCP-инструментов", "что ты умеешь")}${sugg("Опиши активный Blueprint", "разбери граф событий")}${sugg("Открой /Game", "покажи структуру проекта")}
`; + threadEl.appendChild(es); + es.querySelectorAll(".suggest").forEach((s) => s.addEventListener("click", () => { inputEl.value = s.dataset.q; autoGrow(); inputEl.focus(); })); + } + function sugg(t, d) { return ``; } + + // ---- 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", `
⚠ ${esc(data.message || "ошибка")}
`); 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", `
⚠ потеряна связь с гейтвеем
`); 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 = `Generating ElevenLabs variations...`; + 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 = `${esc(e.message || "Sound generation failed")}`; + 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 = `
Модель
` + MODELS.map((m) => + `
${I.spark}
${esc(m.name)}
${esc(m.tag || m.id)}
${m.id === model ? `${I.check}` : ""}
`).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 = `
ничего не найдено
`; mentionPop.style.display = "block"; return; } + mentionPop.innerHTML = menItems.map((a, i) => + `
${I.asset}
${esc(a.name)} ${a.open ? 'OPEN' : ""}
${esc(a.path)}
${esc(a.class || "")}
`).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) => `
${I.x}
`).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) => ``).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(); +})(); diff --git a/agent-gateway/public/index.html b/agent-gateway/public/index.html new file mode 100644 index 0000000..6d64dca --- /dev/null +++ b/agent-gateway/public/index.html @@ -0,0 +1,194 @@ + + + + + + Unreal Copilot + + + +
+ + + + +
+
+
+
Новый разговор
+
ue-blueprint MCP
+
+
+
connecting…
+
+ + +
+
+ +
+
+
+ +
+
+ +
+ +
+ + + + + + + + +
+
+ +
Forge правит Blueprints и C++ через ue-blueprint MCP · отправить · ⇧⏎ перенос
+
+
+
+
+ + + + + +
+ + + diff --git a/agent-gateway/public/styles.css b/agent-gateway/public/styles.css new file mode 100644 index 0000000..79bf927 --- /dev/null +++ b/agent-gateway/public/styles.css @@ -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; } diff --git a/agent-gateway/server.mjs b/agent-gateway/server.mjs new file mode 100644 index 0000000..45563e4 --- /dev/null +++ b/agent-gateway/server.mjs @@ -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`); }); +}); diff --git a/agent-gateway/setup-token.bat b/agent-gateway/setup-token.bat new file mode 100644 index 0000000..f57e93b --- /dev/null +++ b/agent-gateway/setup-token.bat @@ -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 diff --git a/agent-gateway/start-gateway.bat b/agent-gateway/start-gateway.bat new file mode 100644 index 0000000..ae6057d --- /dev/null +++ b/agent-gateway/start-gateway.bat @@ -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 diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..b291d42 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1587 @@ +{ + "name": "ue-blueprint-mcp", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ue-blueprint-mcp", + "version": "0.1.0", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.0.4", + "better-sqlite3": "^11.5.0" + }, + "bin": { + "ue-blueprint-mcp": "src/server.js" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/better-sqlite3": { + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", + "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.8.tgz", + "integrity": "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.19", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.19.tgz", + "integrity": "sha512-xa3eYXYXx68XTT4hZ7dRzsXBhaq85ToSrlUJNoR0gwz/1Ap/CNwX47wfvV7pc/xWhjKVVkLT7zBJy8chhNguqQ==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-abi": { + "version": "3.92.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", + "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..84e75cc --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/buildOrchestrator.js b/src/buildOrchestrator.js new file mode 100644 index 0000000..7cbd836 --- /dev/null +++ b/src/buildOrchestrator.js @@ -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)); } diff --git a/src/insights.js b/src/insights.js new file mode 100644 index 0000000..ae0d8c3 --- /dev/null +++ b/src/insights.js @@ -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): +// /Saved/InsightsMCP/ +// traces.json — index { trace_id: {path, label, ...} } +// / +// 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, "/")}`, + }); +} diff --git a/src/insightsChart.js b/src/insightsChart.js new file mode 100644 index 0000000..ca59376 --- /dev/null +++ b/src/insightsChart.js @@ -0,0 +1,201 @@ +// Pure-SVG chart builders. No dependencies. Each function returns an +// `...` 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, "&").replace(//g, ">"); } +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 5–30ms typical, hitches 100–4000ms) + 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) => ``).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(`` + + `${v}ms`); + } + } + // 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(`` + + `${(t/60).toFixed(0)}m`); + } + + return ` + + ${esc(title)} — ${frames.length} frames, ${hitches.length} hitches >${hitchMs}ms + ${xticks.join("")} + ${ticks.join("")} + + hitch threshold ${hitchMs}ms + + ${hitchDots} +`; +} + +// ----- 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 `${tt}` + + `${edges[i + 1] >= 1000 ? (edges[i + 1] / 1000) + "s" : edges[i + 1]}`; + }).join(""); + + return ` + + ${esc(title)} + 0 + ${maxN} + ${bars} +`; +} + +// ----- 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 `${esc(label)}` + + `` + + `${fmt(v)}${metric.endsWith("_ms") ? "ms" : ""}${r.calls ? ` ×${r.calls}` : ""}`; + }).join(""); + + return ` + + ${esc(title)} + ${bars} +`; +} + +// ----- 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 `${esc(r.thread)}` + + `` + + `${fmt(v)}s`; + }).join(""); + + return ` + + ${esc(title)} + ${bars} +`; +} + +function placeholder(w, h, msg) { + return ` + + ${esc(msg)} +`; +} + +// 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 = ` +Insights ${esc(label)} + +
+

Insights Dashboard — ${esc(label)}

+
trace_id ${esc(traceId)} · generated ${new Date().toISOString()}
+
+
+${sections.map((s) => `
${s.svg}${s.note ? `
${esc(s.note)}
` : ""}
`).join("\n")} +
+`; + return html; +} diff --git a/src/insightsDb.js b/src/insightsDb.js new file mode 100644 index 0000000..309e6a3 --- /dev/null +++ b/src/insightsDb.js @@ -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 })); +} diff --git a/src/insightsExporter.js b/src/insightsExporter.js new file mode 100644 index 0000000..c52009d --- /dev/null +++ b/src/insightsExporter.js @@ -0,0 +1,154 @@ +// Wraps UnrealInsights.exe to export an .utrace file into CSVs. +// +// Strategy: run `UnrealInsights.exe -OpenTraceFile= -AutoQuit +// -ABSLog= -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 }); + }); + }); +} diff --git a/src/launcher.js b/src/launcher.js new file mode 100644 index 0000000..3a9333e --- /dev/null +++ b/src/launcher.js @@ -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 /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 }; } diff --git a/src/nodeSpec.js b/src/nodeSpec.js new file mode 100644 index 0000000..9b40541 --- /dev/null +++ b/src/nodeSpec.js @@ -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: 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:[]}." } + } +}; diff --git a/src/projectPaths.js b/src/projectPaths.js new file mode 100644 index 0000000..7b87512 --- /dev/null +++ b/src/projectPaths.js @@ -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 /Plugins// — i.e. this +// file sits at /Plugins//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)); // /src +export const PLUGIN_DIR = path.resolve(HERE, ".."); // +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 /Plugins/ 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 "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 ".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; +} diff --git a/src/pythonGen.js b/src/pythonGen.js new file mode 100644 index 0000000..d6bc8a2 --- /dev/null +++ b/src/pythonGen.js @@ -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()`. + * 3. Print a result envelope `__MCP_RESULT____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 }; diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..c90a54f --- /dev/null +++ b/src/server.js @@ -0,0 +1,1320 @@ +#!/usr/bin/env node +// MCP server entry point — stdio transport. +// +// Exposes four tools: +// apply_graph — declarative spawn/connect via NodeSpec graph +// introspect — list functions/properties of a UE class (best-effort) +// compile — compile a Blueprint +// read_graph — dump existing graph back as NodeSpec +// +// All UE-touching paths share one bridge (ueBridge.js); when UE is +// unreachable the tools return a structured error rather than throwing. + +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import path from "node:path"; +import { + CallToolRequestSchema, + ListToolsRequestSchema, +} from "@modelcontextprotocol/sdk/types.js"; + +import { APPLY_GRAPH_SCHEMA, validateGraph } from "./nodeSpec.js"; +import { buildApplyCommand, parseResultEnvelope, UE_SIDE_DIR } from "./pythonGen.js"; +import { executePythonInUE, ping } from "./ueBridge.js"; +import { saveAll, quitEditor, runBuild, launchEditor, fullRebuild } from "./buildOrchestrator.js"; +import { handleLauncherOp } from "./launcher.js"; +import { + handleOpenTrace, handleListTraces, handleSummary, + handleFrameStats, handleTopScopes, handleScopeDetail, + handleGpuTop, handleMemoryOverview, handleLoadingTop, + handleCompareTraces, handleQuery, handleReport, handleRenderCharts, +} from "./insights.js"; +import * as PP from "./projectPaths.js"; + +const UE_SIDE_DIR_FOR_JS = UE_SIDE_DIR.replace(/\\/g, "/"); +const DEFAULT_PROJECT_DIR = PP.projectDir(); + +const TOOLS = [ + { + name: "apply_graph", + description: + "Spawn and connect Blueprint nodes from a declarative NodeSpec graph. " + + "Atomic in one undo step. mode=append|replace_function|replace_event. " + + "Explicit node positions are preserved by default; pass layout=compact or auto_layout=true to repack columns. " + + "dry_run=true returns generated Python without sending. " + + "validate=true resolves all targets in UE without spawning (use before big batches).", + inputSchema: APPLY_GRAPH_SCHEMA, + }, + { + name: "introspect", + description: + "List callable functions / properties of a UE class so you can pick valid `target` values for call_function / variable_* nodes. " + + "Args: { class_path: '/Script/Engine.KismetSystemLibrary' | 'KismetMathLibrary' }.", + inputSchema: { + type: "object", + required: ["class_path"], + properties: { class_path: { type: "string" } }, + }, + }, + { + name: "compile", + description: "Compile a Blueprint asset and return compile errors/warnings.", + inputSchema: { + type: "object", + required: ["bp_path"], + properties: { bp_path: { type: "string" } }, + }, + }, + { + name: "read_graph", + description: "Dump an existing Blueprint graph as JSON (nodes + pins + connections). Use to inspect a graph before editing.", + inputSchema: { + type: "object", + properties: { + bp_path: { type: "string", description: "Blueprint path. If omitted, uses Content Browser selection." }, + function: { type: "string", description: "Graph name. 'EventGraph' (default) or a function name." }, + }, + }, + }, + { + name: "apply_batch", + description: "Run multiple apply_graph operations in one round-trip (same UE transaction, single undo). Each op is a graph spec like apply_graph args. Order is preserved.", + inputSchema: { + type: "object", + required: ["ops"], + properties: { + ops: { + type: "array", + items: { + type: "object", + properties: { + op: { type: "string", enum: ["apply", "validate", "read"], description: "Default: apply" }, + bp_path: { type: "string" }, + function: { type: "string" }, + mode: { type: "string", enum: ["append", "replace_function", "replace_event"] }, + layout: { type: "string", enum: ["preserve", "compact", "columns"] }, + auto_layout: { type: "boolean" }, + nodes: { type: "array" }, + edges: { type: "array" }, + }, + }, + }, + }, + }, + }, + { + name: "ping_ue", + description: "Check whether UE Remote Control is reachable. Useful as a first call in any session.", + inputSchema: { type: "object", properties: {} }, + }, + { + name: "get_active_blueprint", + description: "Return the path of the Blueprint currently open in the UE editor (most-recently-focused). Use to verify auto-detect target before calling apply_graph without bp_path.", + inputSchema: { type: "object", properties: {} }, + }, + { + name: "read_last_result", + description: "Read the result envelope of the most recent apply_graph call from /Saved/MCP/last.json. Use after apply_graph to inspect spawned/errors when stdout isn't available.", + inputSchema: { + type: "object", + properties: { + project_dir: { type: "string", description: "Absolute path to the .uproject parent. Auto-detected from the plugin location if omitted." }, + }, + }, + }, + { + name: "discover_op", + description: + "Read-only discovery. Ops: list_open_bps{}, list_graphs{bp_path}, describe_bp{bp_path}, get_selection{bp_path}, resolve_call{bp_path,function?,guid}, find_var_refs{bp_path,name}, find_fn_refs{bp_path,name}, find_bp_by_parent{parent_class_path}, get_refs_of{asset_path}, get_referencers{asset_path}, read_function{bp_path,function}.", + inputSchema: { + type: "object", + required: ["op"], + properties: { + op: { type: "string" }, + bp_path: { type: "string" }, + function: { type: "string" }, + guid: { type: "string" }, + name: { type: "string" }, + parent_class_path: { type: "string" }, + asset_path: { type: "string" }, + }, + }, + }, + { + name: "edit_op", + description: + "Single-node edits + 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}, format_text{bp_path,function?,guid,format}, reconstruct{bp_path,function?,guid}.", + inputSchema: { + type: "object", + required: ["op"], + properties: { + op: { type: "string" }, + bp_path: { type: "string" }, + function: { type: "string" }, + guid: { type: "string" }, + from: { type: "array", items: { type: "string" }, minItems: 2, maxItems: 2 }, + to: { type: "array", items: { type: "string" }, minItems: 2, maxItems: 2 }, + moves: { type: "array" }, + width: { type: "number" }, + height: { type: "number" }, + text: { type: "string" }, + bubble: { type: "boolean" }, + format: { type: "string" }, + }, + }, + }, + { + name: "selection_op", + description: + "Selection-aware partial Blueprint graph work. Ops: get_context{bp_path,function?} returns selected nodes, focused graph, bbox, anchors and MCP Draft Zones; apply_relative{bp_path,function?,zone_guid?,placement:right|below|center,gap_x?,gap_y?,delete_selection?,nodes,edges} appends or replaces only a selected area / draft zone by offsetting NodeSpec positions.", + inputSchema: { + type: "object", + required: ["op", "bp_path"], + properties: { + op: { type: "string", enum: ["get_context", "apply_relative"] }, + bp_path: { type: "string" }, + function: { type: "string" }, + zone_guid: { type: "string" }, + placement: { type: "string", enum: ["right", "below", "center"] }, + gap_x: { type: "number" }, + gap_y: { type: "number" }, + delete_selection: { type: "boolean" }, + nodes: { type: "array" }, + edges: { type: "array" }, + }, + }, + }, + { + name: "network_op", + description: + "Blueprint networking operations. Ops: audit_bp{bp_path,components?}, " + + "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:none|server|client|multicast,reliable?}, " + + "apply_profile{bp_path,profile:replicated_world_actor|always_relevant_world_actor}.", + inputSchema: { + type: "object", + required: ["op", "bp_path"], + properties: { + op: { type: "string" }, + bp_path: { type: "string" }, + function: { type: "string" }, + components: { type: "array", items: { type: "string" } }, + component: { type: "string" }, + event: { type: "string" }, + guid: { type: "string" }, + mode: { type: "string", enum: ["none", "local", "server", "run_on_server", "client", "run_on_owning_client", "multicast", "netmulticast"] }, + reliable: { type: "boolean" }, + profile: { type: "string" }, + replicated: { type: "boolean" }, + net_addressable: { type: "boolean" }, + replicates: { type: "boolean" }, + replicate_movement: { type: "boolean" }, + net_load_on_client: { type: "boolean" }, + always_relevant: { type: "boolean" }, + only_relevant_to_owner: { type: "boolean" }, + use_owner_relevancy: { type: "boolean" }, + net_cull_distance_squared: { type: "number" }, + net_update_frequency: { type: "number" }, + min_net_update_frequency: { type: "number" }, + net_priority: { type: "number" }, + net_dormancy: { type: "string" }, + }, + }, + }, + { + name: "voxel_graph_op", + description: + "Voxel Plugin graph worker. Ops: list_terminals{asset_path}, read_graph{asset_path,terminal?}, get_selection{asset_path,terminal?}, get_context{asset_path,terminal?}, list_node_types{filter?,limit?} (catalogue of spawnable nodes; returns {id,display_name,category,is_pure,kind}), spawn_node{asset_path,terminal?,node,x,y,compile?} (node = id/display name from list_node_types; returns guid), create_graph{path} (new empty Voxel Graph), add_property{asset_path,terminal?,kind,name,type?,category?} (kind=parameter|input|output|local; type=float|double|int|bool|vector2d|vector|color|name; returns guid), list_properties{asset_path,terminal?,kind}, spawn_property{asset_path,terminal?,kind,ref,x,y,declaration?,compile?} (ref=guid/name; declaration only for local), spawn_call{asset_path,terminal?,function,x,y,compile?} (function=function-terminal guid/name), 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}. Typical flow: create_graph -> (add_property params) -> list_node_types -> spawn_node/spawn_property -> read_graph (see pins) -> connect_pins / set_pin_default -> compile.", + inputSchema: { + type: "object", + required: ["op"], + properties: { + op: { type: "string" }, + asset_path: { type: "string", description: "Required by every op except list_node_types and create_graph." }, + path: { type: "string", description: "create_graph: package path for the new graph, e.g. /Game/MyGraph." }, + terminal: { type: "string", description: "Terminal graph: main (default), editor, guid, display name, or ed graph name." }, + node: { type: "string", description: "spawn_node: node id, display name, or 'Library.Function' (from list_node_types)." }, + kind: { type: "string", description: "add_property/list_properties/spawn_property: parameter|input|output|local." }, + name: { type: "string", description: "add_property: property name." }, + type: { type: "string", description: "add_property: float|double|int|bool|vector2d|vector|color|name." }, + category: { type: "string", description: "add_property: optional category." }, + ref: { type: "string", description: "spawn_property: property guid or name." }, + function: { type: "string", description: "spawn_call: function-terminal guid or display name." }, + declaration:{ type: "boolean", description: "spawn_property (kind=local): true = declaration node, false = usage node." }, + filter: { type: "string", description: "list_node_types: case-insensitive substring over id/display name/category." }, + limit: { type: "number", description: "list_node_types: max entries to return (0 = all)." }, + compile: { type: "boolean", description: "spawn_*: recompile the terminal graph after the op (default false)." }, + guid: { type: "string" }, + from: { type: "array", items: { type: "string" }, minItems: 2, maxItems: 2 }, + to: { type: "array", items: { type: "string" }, minItems: 2, maxItems: 2 }, + pin: { type: "string" }, + value: { type: "string" }, + moves: { type: "array" }, + x: { type: "number" }, + y: { type: "number" }, + width: { type: "number" }, + height: { type: "number" }, + text: { type: "string" }, + bubble: { type: "boolean" }, + color: { type: "array", items: { type: "number" }, minItems: 3, maxItems: 4 }, + }, + }, + }, + { + name: "pcg_op", + description: + "PCG (Procedural Content Generation) workflow worker — end-to-end: author graphs, lay them out, wire subgraphs, then spawn/generate them in the level. Pure-Python (engine-exposed UPCGGraph), supports ALL PCG node types — every UPCGSettings subclass bound into Python (engine PCG + PCGExtendedToolkit), enumerated dynamically (no hardcoded table). " + + "AUTHORING: api{} (dump live PCG reflection), create_graph{path} (new UPCGGraph), list_node_types{filter?,limit?} (catalogue; returns {name,title,plugin,path}), read_graph{asset_path} (nodes+pins+edges+io), add_node{asset_path,node,x?,y?,title?}, delete_node{asset_path,node}, connect{asset_path,from:[node,pin?],to:[node,pin?]} (pin optional → first pin), disconnect{...}, list_node_properties{asset_path,node}, set_node_property{asset_path,node,property,value}, move_nodes{asset_path,moves:[{node,x,y}]}, save{asset_path}, open_asset{asset_path}. " + + "DECLARATIVE: apply_graph{asset_path,nodes:[{id,type,x?,y?,title?,properties?}],edges:[{from:[id,pin?],to:[id,pin?]}],outputs?:[{from:[id,pin?],pin?}],clear_existing?,auto_layout?(default true),save?(default true)} builds a whole graph in one call (auto-laid-out by edge depth); auto_layout|tidy_graph|layout{asset_path} re-flows existing nodes. " + + "PARAMS/SUBGRAPH: list_params{asset_path} (read-only — UE blocks Python property-bag editing); set_subgraph{asset_path,node,subgraph} (point a Subgraph node at another PCG graph asset). " + + "RUNTIME: spawn_volume{graph?,location?,rotation?,scale?,name?,generate?} (spawn a PCG Volume, assign graph, generate), set_actor_graph{actor,graph,generate?}, generate{actor,force?}, cleanup{actor,remove_components?}, list_components{} (level actors with a PCGComponent + their graph/state). " + + "PRESETS: scaffold{path|asset_path,template,create?,save?} builds a working graph from a template (template='__list__' → surface_scatter | points_grid_spawn | density_filter_scatter). " + + "Node ref = 'input'|'output'|index|UObject name|settings class short-name/title. Typical flow: scaffold OR apply_graph → read_graph → set_node_property → spawn_volume(generate) → list_components. If a call fails on a method name, run api{}.", + inputSchema: { + type: "object", + required: ["op"], + properties: { + op: { type: "string" }, + asset_path: { type: "string", description: "Graph asset path. Required by most authoring ops (not api/list_node_types/create_graph/scaffold/runtime ops)." }, + path: { type: "string", description: "create_graph/scaffold: package path for the graph, e.g. /Game/PCG/MyGraph." }, + node: { type: "string", description: "add_node/set_subgraph: node type or node ref (input|output|index|name|class)." }, + type: { type: "string", description: "add_node: alias for 'node'." }, + title: { type: "string", description: "add_node: optional node title/comment." }, + property: { type: "string", description: "set_node_property: editor property on the node's settings." }, + value: { description: "set_node_property: value to assign (any JSON type; [x,y,z] → Vector/Rotator)." }, + filter: { type: "string", description: "list_node_types: case-insensitive substring over name/title." }, + limit: { type: "number", description: "list_node_types: max entries (0 = all)." }, + from: { type: "array", items: {}, minItems: 1, maxItems: 2, description: "connect/disconnect: [node, pin?]." }, + to: { type: "array", items: {}, minItems: 1, maxItems: 2, description: "connect/disconnect: [node, pin?]." }, + moves: { type: "array", description: "move_nodes: [{node,x,y}]." }, + x: { type: "number" }, + y: { type: "number" }, + // declarative authoring + nodes: { type: "array", description: "apply_graph: [{id,type,x?,y?,title?,properties?:{}}]." }, + edges: { type: "array", description: "apply_graph: [{from:[id,pin?],to:[id,pin?]}]." }, + outputs: { type: "array", description: "apply_graph: [{from:[id,pin?],pin?}] wired to the graph Output node." }, + clear_existing: { type: "boolean", description: "apply_graph: remove existing nodes first." }, + auto_layout: { type: "boolean", description: "apply_graph: auto-arrange into edge-depth columns (default true)." }, + save: { type: "boolean", description: "apply_graph/scaffold: save asset after (default true)." }, + // subgraph + subgraph: { type: "string", description: "set_subgraph: target PCG graph asset path." }, + // runtime + graph: { type: "string", description: "spawn_volume/set_actor_graph: PCG graph asset path to assign." }, + actor: { type: "string", description: "set_actor_graph/generate/cleanup: actor label/name/path." }, + location: { type: "array", items: { type: "number" }, minItems: 3, maxItems: 3, description: "spawn_volume: world location [x,y,z]." }, + rotation: { type: "array", items: { type: "number" }, minItems: 3, maxItems: 3, description: "spawn_volume: rotation [pitch,yaw,roll]." }, + scale: { type: "array", items: { type: "number" }, minItems: 3, maxItems: 3, description: "spawn_volume: scale [x,y,z]." }, + name: { type: "string", description: "spawn_volume: actor label for the new volume." }, + generate: { type: "boolean", description: "spawn_volume/set_actor_graph: generate after assigning the graph." }, + force: { type: "boolean", description: "generate: force regeneration (default true)." }, + remove_components: { type: "boolean", description: "cleanup: also destroy generated components (default true)." }, + // presets + template: { type: "string", description: "scaffold: template name or '__list__'." }, + create: { type: "boolean", description: "scaffold: create the graph asset if missing (default true)." }, + }, + }, + }, + { + name: "material_op", + description: + "Material authoring and material instance ops. " + + "Ops: create_material{path}, create_material_instance{path,parent?}, read_graph{asset_path}, " + + "apply_graph{asset_path,clear_existing?,auto_layout?,layout?,save?,nodes:[{id,class,position?,properties?}],edges:[{from:[idOrGuid,output],to:[idOrGuid,input]}],outputs:{BaseColor:[idOrGuid,output]}}, " + + "add_node{asset_path,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}, tidy_graph{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}.", + inputSchema: { + type: "object", + required: ["op"], + properties: { + op: { type: "string" }, + path: { type: "string" }, + asset_path: { type: "string" }, + parent: { type: "string" }, + id: { type: "string" }, + class: { type: "string", description: "Expression class, e.g. Constant3Vector, TextureSample, ScalarParameter, /Script/Engine.MaterialExpressionMultiply" }, + guid: { type: "string" }, + name: { type: "string" }, + property: { type: "string" }, + value: {}, + position: { type: "array", items: { type: "number" }, minItems: 2, maxItems: 2 }, + properties: { type: "object" }, + nodes: { type: "array" }, + edges: { type: "array" }, + outputs: { type: "object" }, + from: { type: "array", items: { type: "string" }, minItems: 2, maxItems: 2 }, + to: { type: "array", items: { type: "string" }, minItems: 2, maxItems: 2 }, + moves: { type: "array" }, + clear_existing: { type: "boolean" }, + auto_layout: { type: "boolean" }, + layout: { type: "boolean" }, + save: { type: "boolean" }, + }, + }, + }, + { + name: "preset_op", + description: + "Apply a packaged Blueprint pattern. Send name='__list__' to see available presets. Built-in: print_on_begin_play{bp_path,text?}, print_on_event{bp_path,event,text?}, toggle_visibility{bp_path,event?,component}, damage_drops_health{bp_path,health_var?}.", + inputSchema: { + type: "object", + required: ["name"], + properties: { + name: { type: "string", description: "Preset name or '__list__'." }, + args: { type: "object", description: "Preset-specific arguments." }, + }, + }, + }, + { + name: "asset_op", + description: + "Asset CRUD. Ops: create_blueprint{path,parent_class}, create_widget_bp{path}, create_enum{path,values}, create_struct{path}, 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}.", + inputSchema: { + type: "object", + required: ["op"], + properties: { + op: { type: "string" }, + path: { type: "string" }, + src: { type: "string" }, + dst: { type: "string" }, + dst_folder: { type: "string" }, + parent_class: { type: "string" }, + new_parent_class: { type: "string" }, + class: { type: "string" }, + bp: { type: "string" }, + values: { type: "array", items: { type: "string" } }, + fields: { type: "array" }, + row_struct: { type: "string" }, + }, + }, + }, + { + name: "variable_op", + description: + "Blueprint member-variable authoring (the apply_graph node-spec can reference variables but cannot CREATE them — use this first). " + + "Ops: add{bp_path,name,type,default?,instance_editable?,expose_on_spawn?,category?,tooltip?} — type is 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}. " + + "Compiles + saves the Blueprint after each mutating op so subsequent variable_get/variable_set nodes resolve.", + inputSchema: { + type: "object", + required: ["op"], + properties: { + op: { type: "string" }, + bp_path: { type: "string" }, + name: { type: "string" }, + type: { type: "string" }, + value: { type: "string" }, + default: { type: "string" }, + old: { type: "string" }, + new: { type: "string" }, + category: { type: "string" }, + tooltip: { type: "string" }, + instance_editable:{ type: "boolean" }, + expose_on_spawn: { type: "boolean" }, + }, + }, + }, + { + name: "widget_op", + description: + "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:[...]}, compile_save{bp_path}, " + + "screenshot{bp_path,size?:[1920,1080],out_dir?:'MCP/widgets'} — VISION LOOP: renders the WBP to a PNG and returns file:// path. Use AFTER any UI change to verify visual result. AI MUST attach the returned image to its next thought before iterating.", + inputSchema: { + type: "object", + required: ["op", "bp_path"], + properties: { + op: { type: "string" }, + bp_path: { type: "string" }, + class: { type: "string" }, + name: { type: "string" }, + widget: { type: "string" }, + new_name: { type: "string" }, + parent: { type: "string" }, + index: { type: "number" }, + is_variable: { type: "boolean" }, + property: { type: "string" }, + value: { type: "string" }, + properties: { type: "object" }, + canvas_slot: { type: "object" }, + position: { type: "array", items: { type: "number" }, minItems: 2, maxItems: 2 }, + size: { type: "array", items: { type: "number" }, minItems: 2, maxItems: 2 }, + anchors_min: { type: "array", items: { type: "number" }, minItems: 2, maxItems: 2 }, + anchors_max: { type: "array", items: { type: "number" }, minItems: 2, maxItems: 2 }, + alignment: { type: "array", items: { type: "number" }, minItems: 2, maxItems: 2 }, + auto_size: { type: "boolean" }, + z_order: { type: "number" }, + host: { type: "string" }, + slot: { type: "string" }, + content: { type: "string" }, + clear_existing: { type: "boolean" }, + widgets: { type: "array" }, + desired_focus: { type: "string" }, + out_dir: { type: "string" }, + }, + }, + }, + { + name: "level_op", + description: + "Level/scene ops. Ops: spawn_actor{class,location?,rotation?,scale?,name?}, destroy{name}, get_selected{}, 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{}.", + inputSchema: { + type: "object", + required: ["op"], + properties: { + op: { type: "string" }, + class: { type: "string" }, + name: { type: "string" }, + names: { type: "array", items: { type: "string" } }, + child: { type: "string" }, + parent: { type: "string" }, + socket: { type: "string" }, + path: { type: "string" }, + location: { type: "array", items: { type: "number" }, minItems: 3, maxItems: 3 }, + rotation: { type: "array", items: { type: "number" }, minItems: 3, maxItems: 3 }, + scale: { type: "array", items: { type: "number" }, minItems: 3, maxItems: 3 }, + }, + }, + }, + { + name: "niagara_op", + description: + "Niagara VFX authoring. Requires C++ rebuild (MCPNiagaraLibrary). " + + "Ops: create_system{path}, create_emitter_asset{path}, " + + "add_emitter{system,emitter_asset,name?}, remove_emitter{system,emitter_name}, list_emitters{system}, " + + "list_modules{system,emitter_name?,group}, add_module{system,emitter_name?,group,module_script,index?}, remove_module{system,emitter_name?,group,module_name}, " + + "add_renderer{system,emitter_name,renderer_class}, remove_renderer{system,emitter_name,renderer_class}, " + + "compile{system}. " + + "Groups: EmitterSpawn|EmitterUpdate|ParticleSpawn|ParticleUpdate|SystemSpawn|SystemUpdate. " + + "set_module_input is stubbed (use UI for now).", + inputSchema: { + type: "object", + required: ["op"], + properties: { + op: { type: "string" }, + path: { type: "string" }, + system: { type: "string", description: "Path to UNiagaraSystem asset (/Game/...)" }, + emitter_asset: { type: "string", description: "Path to UNiagaraEmitter asset to add to system" }, + emitter_name: { type: "string", description: "Handle name inside the system" }, + name: { type: "string" }, + group: { type: "string", enum: ["EmitterSpawn", "EmitterUpdate", "ParticleSpawn", "ParticleUpdate", "SystemSpawn", "SystemUpdate"] }, + module_script: { type: "string", description: "Path to a UNiagaraScript module asset" }, + module_name: { type: "string" }, + index: { type: "number" }, + renderer_class: { type: "string", description: "e.g. /Script/Niagara.NiagaraSpriteRendererProperties" }, + }, + }, + }, + { + name: "animation_op", + description: + "Animation authoring. Ops: create_anim_blueprint{path,skeleton}, create_anim_montage{path,sequence?}, create_blend_space{path,skeleton,dim?:1|2}, " + + "get_sequence_info{path}, 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}, retarget_animation{src,dst_skeleton,dst_path}.", + inputSchema: { + type: "object", + required: ["op"], + properties: { + op: { type: "string" }, + path: { type: "string" }, + anim_bp: { type: "string" }, + sequence: { type: "string" }, + skeleton: { type: "string" }, + folder: { type: "string" }, + bone: { type: "string" }, + name: { type: "string" }, + time: { type: "number" }, + start_time: { type: "number" }, + index: { type: "number" }, + dim: { type: "number" }, + notify_class: { type: "string" }, + src: { type: "string" }, + dst_skeleton: { type: "string" }, + dst_path: { type: "string" }, + location: { type: "array", items: { type: "number" }, minItems: 3, maxItems: 3 }, + rotation: { type: "array", items: { type: "number" }, minItems: 3, maxItems: 3 }, + }, + }, + }, + { + name: "audio_op", + description: + "Audio assets. 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}.", + inputSchema: { + type: "object", + required: ["op"], + properties: { + op: { type: "string" }, path: { type: "string" }, folder: { type: "string" }, + property: { type: "string" }, value: {}, + }, + }, + }, + { + name: "ai_op", + description: + "AI assets (BehaviorTree, Blackboard, EQS, AIController BP). Ops: create_behavior_tree{path,blackboard?}, create_blackboard{path,parent?}, " + + "create_eqs_query{path}, create_aicontroller_bp{path,parent_class?}, 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}.", + inputSchema: { + type: "object", + required: ["op"], + properties: { + op: { type: "string" }, path: { type: "string" }, name: { type: "string" }, + type: { type: "string" }, base_class: { type: "string" }, blackboard: { type: "string" }, + parent: { type: "string" }, parent_class: { type: "string" }, + bt_path: { type: "string" }, blackboard_path: { type: "string" }, + }, + }, + }, + { + name: "input_op", + description: + "Enhanced Input authoring. Ops: create_input_action{path,value_type?:'Bool|Axis1D|Axis2D|Axis3D'}, create_input_mapping_context{path}, " + + "add_mapping{imc_path,action_path,key,modifiers?,triggers?}, remove_mapping{imc_path,index}, list_mappings{imc_path}. " + + "modifiers/triggers are arrays of UClass paths e.g. '/Script/EnhancedInput.InputTriggerHold'.", + inputSchema: { + type: "object", + required: ["op"], + properties: { + op: { type: "string" }, path: { type: "string" }, value_type: { type: "string" }, + imc_path: { type: "string" }, action_path: { type: "string" }, key: { type: "string" }, + index: { type: "number" }, + modifiers: { type: "array", items: { type: "string" } }, + triggers: { type: "array", items: { type: "string" } }, + }, + }, + }, + { + name: "mesh_op", + description: + "Static / Skeletal mesh ops. Ops: static_info{path}, skeletal_info{path}, list_sockets{path}, 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'}.", + inputSchema: { + type: "object", + required: ["op"], + properties: { + op: { type: "string" }, path: { type: "string" }, name: { type: "string" }, bone: { type: "string" }, + lod_index: { type: "number" }, screen_size: { type: "number" }, + complexity: { type: "string" }, shape: { type: "string" }, + location: { type: "array", items: { type: "number" }, minItems: 3, maxItems: 3 }, + rotation: { type: "array", items: { type: "number" }, minItems: 3, maxItems: 3 }, + scale: { type: "array", items: { type: "number" }, minItems: 3, maxItems: 3 }, + }, + }, + }, + { + name: "render_op", + description: + "Render-side ops. Ops: spawn_post_process_volume{location?,unbound?:true,settings?,name?}, set_pp_setting{actor_name,key,value}, list_pp_volumes{}, " + + "create_render_target_2d{path,width?,height?,format?:'RGBA16f|RGBA8'}, spawn_scene_capture_2d{target_render_target_path,location?,rotation?,name?}, " + + "take_screenshot{filename?,resolution?,hdr?}, set_viewport_realtime{enabled}, capture_thumbnail{asset_path}.", + inputSchema: { + type: "object", + required: ["op"], + properties: { + op: { type: "string" }, path: { type: "string" }, name: { type: "string" }, + actor_name: { type: "string" }, key: { type: "string" }, value: {}, + target_render_target_path: { type: "string" }, asset_path: { type: "string" }, + filename: { type: "string" }, resolution: { type: "string" }, + width: { type: "number" }, height: { type: "number" }, format: { type: "string" }, + unbound: { type: "boolean" }, hdr: { type: "boolean" }, enabled: { type: "boolean" }, + settings: { type: "object" }, + location: { type: "array", items: { type: "number" }, minItems: 3, maxItems: 3 }, + rotation: { type: "array", items: { type: "number" }, minItems: 3, maxItems: 3 }, + }, + }, + }, + { + name: "capture_op", + description: + "HEADLESS VISION — see editor content WITHOUT opening any tab/window. Returns a PNG INLINE (you see the image directly) plus its file path. " + + "Ops: asset{asset_path,size?:[512,512]} renders ANY asset's thumbnail offscreen (mesh, material, texture, blueprint, niagara, anim, sound, data asset — no asset editor needed); " + + "thumbnail/material/mesh are aliases of asset; " + + "scene{location?:[x,y,z],rotation?:[pitch,yaw,roll],fov?:90,size?:[1280,720]} renders the editor world from an arbitrary camera via offscreen SceneCapture2D (no viewport); " + + "scene_from_actor{actor,distance?,pitch?:-20,yaw?:0,fov?:75,size?} frames a level actor and captures it; " + + "list_assets{dir,recursive?} lists asset paths to capture. " + + "Prefer this over render_op take_screenshot (which grabs the live viewport).", + inputSchema: { + type: "object", + required: ["op"], + properties: { + op: { type: "string" }, + asset_path: { type: "string" }, + material_path: { type: "string" }, + mesh_path: { type: "string" }, + path: { type: "string" }, + dir: { type: "string" }, + recursive: { type: "boolean" }, + actor: { type: "string" }, + name: { type: "string" }, + out_dir: { type: "string" }, + fov: { type: "number" }, + distance: { type: "number" }, + pitch: { type: "number" }, + yaw: { type: "number" }, + size: { type: "array", items: { type: "number" }, minItems: 2, maxItems: 2 }, + location: { type: "array", items: { type: "number" }, minItems: 3, maxItems: 3 }, + rotation: { type: "array", items: { type: "number" }, minItems: 3, maxItems: 3 }, + }, + }, + }, + { + name: "cull_op", + description: + "Batch cull-distance (Desired Max Draw Distance) on ISM/HISM/StaticMesh components. " + + "Ops: audit{scope?,asset_paths?,component_types?,target?} (read-only report), " + + "apply{distance,scope?,asset_paths?,component_types?,only_zero?:true,target?,save?:true}, verify{...} (alias of audit). " + + "target='asset_template' (default) edits the Blueprint asset = default for all instances, survives reconstruct; " + + "target='level_instance' edits placed actors only (lost on BP recompile / PLA repack). " + + "scope='level'(default)|'assets'|'selection'. component_types default ['ISM']. only_zero keeps existing non-zero values.", + inputSchema: { + type: "object", + required: ["op"], + properties: { + op: { type: "string" }, + target: { type: "string", enum: ["asset_template", "level_instance"] }, + scope: { type: "string", enum: ["level", "assets", "selection"] }, + asset_paths: { type: "array", items: { type: "string" } }, + component_types: { type: "array", items: { type: "string", enum: ["ISM", "HISM", "StaticMesh", "Primitive"] } }, + distance: { type: "number" }, + only_zero: { type: "boolean" }, + save: { type: "boolean" }, + }, + }, + }, + { + name: "console_op", + description: + "Editor console commands and CVars. Ops: run{command}, get_cvar{name}, set_cvar{name,value}, list_cvars{prefix?}, stat{name,enable?}. " + + "Use read_python_log to read echoed output afterwards.", + inputSchema: { + type: "object", + required: ["op"], + properties: { + op: { type: "string" }, command: { type: "string" }, name: { type: "string" }, + value: {}, prefix: { type: "string" }, enable: { type: "boolean" }, + }, + }, + }, + { + name: "validation_op", + description: + "Asset validation, redirector cleanup, reference analysis. Ops: validate_assets{paths:[]}, validate_folder{path,recursive?}, fix_redirectors{path}, " + + "get_references{asset_path}, get_referencers{asset_path}, find_unused{folder,ignore_paths?}, disk_size{folder}, list_redirectors{folder}.", + inputSchema: { + type: "object", + required: ["op"], + properties: { + op: { type: "string" }, path: { type: "string" }, folder: { type: "string" }, asset_path: { type: "string" }, + recursive: { type: "boolean" }, + paths: { type: "array", items: { type: "string" } }, + ignore_paths: { type: "array", items: { type: "string" } }, + }, + }, + }, + { + name: "live_coding_op", + description: + "Live Coding control. Use BEFORE assuming a C++ rebuild will work — checks status, triggers compile, parses LiveCodingConsole.log for results. " + + "Ops: status{}, compile{}, enable{}, disable{}, open_lc_console{}, get_errors{tail?:200}, last_compile{} — last_compile returns {status:'success|failed|in_progress|no_recent', duration_s, timestamp, errors, cycle_lines} parsed from Engine/Programs/LiveCodingConsole/Saved/Logs.", + inputSchema: { + type: "object", + required: ["op"], + properties: { op: { type: "string" }, tail: { type: "number" } }, + }, + }, + { + name: "cpp_scaffold_op", + description: + "Generate C++ class .h/.cpp files under Source//(Public|Private). Pair with live_coding_op compile to register. " + + "Ops: list_modules{}, create_class{name,parent_class,module?(defaults to the project's primary game module),subfolder?,header_only?,public?,base_header?,api_macro?}, list_classes{module?,subfolder?}, read_class{name,module?}. " + + "name must start with A (actors) or U (UObjects) followed by PascalCase. parent_class e.g. AActor, UObject, UActorComponent.", + inputSchema: { + type: "object", + required: ["op"], + properties: { + op: { type: "string" }, name: { type: "string" }, parent_class: { type: "string" }, + module: { type: "string" }, subfolder: { type: "string" }, + header_only: { type: "boolean" }, public: { type: "boolean" }, + base_header: { type: "string" }, api_macro: { type: "string" }, + }, + }, + }, + { + name: "build_op", + description: + "Build orchestrator — drives a full C++ rebuild cycle from outside UE. Use when Live Coding can't help (new UCLASS, new module dep, plugin changes). " + + "Ops: save_all{}, close_editor{}, build{target?,config?,platform?,project_dir?,engine_dir?}, launch_editor{}, full_rebuild{auto_reopen?:true, skip_save?, skip_close?, ...build_args}. " + + "Defaults are auto-detected: project_dir = nearest .uproject above the plugin, target = Editor, config=Development, platform=Win64, engine_dir = the project's associated/installed UE (override any via args or env UE_PROJECT_DIR/UE_BUILD_TARGET/UE_ENGINE_DIR). " + + "full_rebuild returns step-by-step log + build errors/warnings on failure.", + inputSchema: { + type: "object", + required: ["op"], + properties: { + op: { type: "string", enum: ["save_all", "close_editor", "build", "launch_editor", "full_rebuild"] }, + target: { type: "string" }, + config: { type: "string", enum: ["Debug", "DebugGame", "Development", "Test", "Shipping"] }, + platform: { type: "string" }, + project_dir: { type: "string" }, + engine_dir: { type: "string" }, + uproject_name:{ type: "string" }, + auto_reopen: { type: "boolean" }, + skip_save: { type: "boolean" }, + skip_close: { type: "boolean" }, + }, + }, + }, + { + name: "insights_open_trace", + description: + "Open and parse an Unreal Insights .utrace file. Runs UnrealInsights.exe headless to export all available channels into CSVs, then ingests into sqlite. Returns trace_id used by other insights_* tools. Cached by file hash — re-call is instant unless force=true. Args: trace_path (abs path to .utrace), label?, force?, project_dir?.", + inputSchema: { + type: "object", + required: ["trace_path"], + properties: { + trace_path: { type: "string" }, + label: { type: "string" }, + force: { type: "boolean", description: "Re-export even if cache exists" }, + project_dir: { type: "string" }, + }, + }, + }, + { + name: "insights_list_traces", + description: "List all previously-opened traces with their trace_ids, labels and paths.", + inputSchema: { type: "object", properties: { project_dir: { type: "string" } } }, + }, + { + name: "insights_summary", + description: "Quick overview of a trace: available tables, row counts, time span. Use to see what channels were recorded.", + inputSchema: { + type: "object", required: ["trace_id"], + properties: { trace_id: { type: "string" }, project_dir: { type: "string" } }, + }, + }, + { + name: "insights_frame_stats", + description: "Frame-time distribution and worst hitches. Args: trace_id, hitch_ms?(33), top_n?(20).", + inputSchema: { + type: "object", required: ["trace_id"], + properties: { + trace_id: { type: "string" }, + hitch_ms: { type: "number" }, + top_n: { type: "number" }, + project_dir: { type: "string" }, + }, + }, + }, + { + name: "insights_top_scopes", + description: "Top-N CPU scopes by inclusive (default) or exclusive time. Args: trace_id, thread?('game'|'render'|'gpu'|'rhi'), limit?(20), sort_by?('incl'|'excl').", + inputSchema: { + type: "object", required: ["trace_id"], + properties: { + trace_id: { type: "string" }, + thread: { type: "string" }, + limit: { type: "number" }, + sort_by: { type: "string", enum: ["incl", "excl"] }, + project_dir: { type: "string" }, + }, + }, + }, + { + name: "insights_scope_detail", + description: "Stats for one scope: call count, avg/max/min ms, worst N instances with timestamps.", + inputSchema: { + type: "object", required: ["trace_id", "name"], + properties: { + trace_id: { type: "string" }, + name: { type: "string" }, + project_dir: { type: "string" }, + }, + }, + }, + { + name: "insights_gpu_top", + description: "Top-N GPU events by total time. Requires 'gpu' channel in trace.", + inputSchema: { + type: "object", required: ["trace_id"], + properties: { + trace_id: { type: "string" }, + limit: { type: "number" }, + project_dir: { type: "string" }, + }, + }, + }, + { + name: "insights_memory_overview", + description: "LLM tags / memtags: top consumers by peak/current bytes. Requires 'memory' channel.", + inputSchema: { + type: "object", required: ["trace_id"], + properties: { trace_id: { type: "string" }, project_dir: { type: "string" } }, + }, + }, + { + name: "insights_loading_top", + description: "Slowest asset/package loads. Requires 'loadtime' or 'asset' channels.", + inputSchema: { + type: "object", required: ["trace_id"], + properties: { + trace_id: { type: "string" }, + limit: { type: "number" }, + project_dir: { type: "string" }, + }, + }, + }, + { + name: "insights_compare_traces", + description: "Diff two traces: frame stats deltas, top scopes that regressed or improved. Args: trace_a, trace_b.", + inputSchema: { + type: "object", required: ["trace_a", "trace_b"], + properties: { + trace_a: { type: "string" }, trace_b: { type: "string" }, + project_dir: { type: "string" }, + }, + }, + }, + { + name: "insights_query", + description: "Run a read-only SELECT against the trace's sqlite cache. Use insights_summary first to see table names and columns. SELECT only — DDL/DML rejected.", + inputSchema: { + type: "object", required: ["trace_id", "sql"], + properties: { + trace_id: { type: "string" }, + sql: { type: "string" }, + project_dir: { type: "string" }, + }, + }, + }, + { + name: "insights_render_charts", + description: + "Render SVG charts (frame timeline, histogram, per-thread breakdown, top scopes Game/Render/RHI/GPU) and bundle them into a dashboard.html. Open the returned file:// URL in any browser. No memory charts unless the trace was recorded with memory/llm channels.", + inputSchema: { + type: "object", required: ["trace_id"], + properties: { + trace_id: { type: "string" }, + hitch_ms: { type: "number", description: "Hitch threshold for timeline markers (default 33)" }, + project_dir: { type: "string" }, + }, + }, + }, + { + name: "insights_report", + description: "Generate a markdown summary across all domains (frames, scopes, GPU, memory, loading). Saves to /Saved/InsightsMCP//report.md unless save=false.", + inputSchema: { + type: "object", required: ["trace_id"], + properties: { + trace_id: { type: "string" }, + save: { type: "boolean" }, + project_dir: { type: "string" }, + }, + }, + }, + { + name: "read_python_log", + description: "Tail the UE log file and return only Python-related lines (LogPython entries + Python tracebacks). Use to diagnose what went wrong in the editor without screenshots.", + inputSchema: { + type: "object", + properties: { + project_dir: { type: "string", description: "Auto-detected from the plugin location if omitted." }, + log_name: { type: "string", description: "Log filename. Defaults to .log." }, + max_lines: { type: "number", description: "How many of the last matching lines to return. Default 80." }, + errors_only: { type: "boolean", description: "If true, return only Error/Warning/Traceback lines. Default false." }, + }, + }, + }, + { + name: "launcher_op", + description: + "Read & edit UE Project Launcher launch profiles directly — JS-native, no Remote Control, works even when the editor is busy/crashed. " + + "Covers BOTH the Legacy Project Launcher AND the new Project Launcher (UE 5.5+): they share the same LauncherServices/ILauncherProfileManager backend and the same on-disk files, so a profile saved in either shows up here. (New-launcher 'Basic' profiles are transient/per-target and never hit disk; only saved Custom/Advanced profiles are addressable.) " + + "Profiles are *.ulp2 JSON files under /Engine/Programs/UnrealFrontend/Profiles/. Every edit writes a .bak first; delete is a soft-move to _mcp_trash (reversible). " + + "Ops: list{} (summary of all profiles), read{id|name|file} (full JSON), get{id|name|file,field}, " + + "set_field{id|name|file,field,value}, set_fields{id|name|file,fields:{...}}, " + + "set_cooked_maps{id|name|file,maps:[]}, add_cooked_map{id|name|file,map}, remove_cooked_map{id|name|file,map}, " + + "duplicate{id|name|file,new_name?,fields?} / create_profile (alias — copies an existing profile as template, fresh Id), delete{id|name|file}. " + + "Select a profile by {id} (GUID), {name} (exact, else substring), or {file} (filename). " + + "Folder override: {profiles_dir} or {engine_dir} (else env UE_PROFILES_DIR / UE_ENGINE_DIR; the engine is auto-detected from the project's association/install). " + + "CAVEAT: if the Launcher window is open it may overwrite file edits on its next save — edit with it closed, then reopen.", + inputSchema: { + type: "object", + required: ["op"], + properties: { + op: { type: "string", enum: ["list", "read", "get", "set_field", "set_fields", "set_cooked_maps", "add_cooked_map", "remove_cooked_map", "duplicate", "create_profile", "delete"] }, + id: { type: "string", description: "Select profile by GUID (Id field)." }, + name: { type: "string", description: "Select profile by Name (exact, else substring)." }, + file: { type: "string", description: "Select profile by .ulp2 filename or absolute path." }, + field: { type: "string", description: "get/set_field: top-level profile key, e.g. PackageDir, BuildConfiguration, CreateReleaseVersionName." }, + value: { description: "set_field: new value (any JSON type)." }, + fields: { type: "object", description: "set_fields / duplicate: map of key→value to assign." }, + maps: { type: "array", items: { type: "string" }, description: "set_cooked_maps: full replacement list." }, + map: { type: "string", description: "add_cooked_map / remove_cooked_map: a single map name." }, + new_name: { type: "string", description: "duplicate: name for the new profile (default ' copy')." }, + profiles_dir: { type: "string", description: "Override the Profiles folder directly." }, + engine_dir: { type: "string", description: "Engine root (no trailing /Engine); Profiles resolved under it." }, + }, + }, + }, +]; + +const server = new Server( + { name: "ue-blueprint-mcp", version: "0.1.0" }, + { capabilities: { tools: {} } } +); + +server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS })); + +server.setRequestHandler(CallToolRequestSchema, async (req) => { + const { name, arguments: args } = req.params; + try { + switch (name) { + case "apply_graph": return await handleApplyGraph(args); + case "apply_batch": return await handleApplyBatch(args); + case "introspect": return await handleIntrospect(args); + case "compile": return await handleCompile(args); + case "read_graph": return await handleReadGraph(args || {}); + case "ping_ue": return await handlePing(); + case "get_active_blueprint": return await handleGetActiveBp(); + case "read_last_result": return await handleReadLastResult(args || {}); + case "read_python_log": return await handleReadPythonLog(args || {}); + case "asset_op": return await handleModuleOp("assets", args); + case "variable_op": return await handleModuleOp("variables", args); + case "widget_op": return await attachInlineImage(await handleModuleOp("widget_tree", args)); + case "capture_op": return await attachInlineImage(await handleModuleOp("capture", args)); + case "level_op": return await handleModuleOp("level", args); + case "discover_op": return await handleModuleOp("discover", args); + case "edit_op": return await handleModuleOp("edit_ops", args); + case "selection_op": return await handleModuleOp("selection", args); + case "network_op": return await handleModuleOp("network", args); + case "voxel_graph_op": return await handleModuleOp("voxel_graph", args); + case "pcg_op": return await handleModuleOp("pcg", args); + case "material_op": return await handleModuleOp("materials", args); + case "niagara_op": return await handleModuleOp("niagara", args); + case "preset_op": return await handlePresetOp(args || {}); + case "animation_op": return await handleModuleOp("animation", args); + case "audio_op": return await handleModuleOp("audio", args); + case "ai_op": return await handleModuleOp("ai", args); + case "input_op": return await handleModuleOp("input", args); + case "mesh_op": return await handleModuleOp("mesh", args); + case "render_op": return await handleModuleOp("render", args); + case "cull_op": return await handleModuleOp("cull", args); + case "console_op": return await handleModuleOp("console", args); + case "validation_op": return await handleModuleOp("validation", args); + case "live_coding_op": return await handleModuleOp("live_coding", args); + case "cpp_scaffold_op": return await handleModuleOp("cpp_scaffold", args); + case "build_op": return await handleBuildOp(args || {}); + case "launcher_op": return await handleLauncherOp(args || {}); + case "insights_open_trace": return await handleOpenTrace(args || {}); + case "insights_list_traces": return await handleListTraces(args || {}); + case "insights_summary": return await handleSummary(args || {}); + case "insights_frame_stats": return await handleFrameStats(args || {}); + case "insights_top_scopes": return await handleTopScopes(args || {}); + case "insights_scope_detail": return await handleScopeDetail(args || {}); + case "insights_gpu_top": return await handleGpuTop(args || {}); + case "insights_memory_overview": return await handleMemoryOverview(args || {}); + case "insights_loading_top": return await handleLoadingTop(args || {}); + case "insights_compare_traces": return await handleCompareTraces(args || {}); + case "insights_query": return await handleQuery(args || {}); + case "insights_report": return await handleReport(args || {}); + case "insights_render_charts": return await handleRenderCharts(args || {}); + default: return errResult(`unknown tool: ${name}`); + } + } catch (e) { + return errResult(`tool ${name} threw: ${e.stack || e.message}`); + } +}); + +async function handleApplyGraph(args) { + const errors = validateGraph(args); + if (errors.length) return errResult("invalid graph:\n - " + errors.join("\n - ")); + + if (args.validate) { + const rid = makeRid(); + const payload = { ...args, __op: "validate", __rid: rid }; + const cmd = buildApplyCommand(payload); + const sent = await executePythonInUE(cmd); + if (!sent.ok) return errResult(sent.error || "UE call failed"); + return await readRidResult(rid); + } + + if (args.dry_run) { + return okResult({ dry_run: true, python: buildApplyCommand(args) }); + } + + const rid = makeRid(); + const payload = { ...args, __rid: rid }; + const cmd = buildApplyCommand(payload); + const sent = await executePythonInUE(cmd); + if (!sent.ok) return errResult(sent.error || "UE call failed"); + return await readRidResult(rid); +} + +async function handleApplyBatch({ ops }) { + if (!Array.isArray(ops) || ops.length === 0) { + return errResult("apply_batch: ops must be a non-empty array"); + } + const rid = makeRid(); + const payload = { __op: "batch", ops, __rid: rid }; + const cmd = buildApplyCommand(payload); + const sent = await executePythonInUE(cmd); + if (!sent.ok) return errResult(sent.error || "UE call failed"); + return await readRidResult(rid); +} + +async function handleIntrospect({ class_path }) { + const py = [ + "import json, unreal", + `cls = unreal.load_class(None, ${pyStr(class_path)})`, + "out = {'class': str(cls), 'functions': [], 'properties': []}", + "if cls is not None:", + " try: out['functions'] = [f.get_name() for f in unreal.get_functions(cls)]", + " except Exception: pass", + " try: out['properties'] = [p.get_name() for p in unreal.get_properties(cls)]", + " except Exception: pass", + "print('__MCP_RESULT__' + json.dumps(out) + '__MCP_END__')", + ].join("\n"); + const sent = await executePythonInUE(py); + if (!sent.ok) return errResult(sent.error || "UE call failed"); + return okResult({ note: "see UE OutputLog for envelope", rc_response: sent.response }); +} + +async function handleCompile({ bp_path }) { + const rid = makeRid(); + const py = [ + "import json, unreal, os", + `bp = unreal.EditorAssetLibrary.load_asset(${pyStr(bp_path)})`, + "out = {'ok': False, 'bp_path': " + pyStr(bp_path) + "}", + "if bp is None:", + " out['error'] = 'blueprint not found'", + "else:", + " try:", + " msgs = list(unreal.MCPGraphLibrary.compile_with_messages(bp))", + " out['messages'] = msgs", + " out['has_errors'] = any(m.startswith('ERROR|') for m in msgs)", + " out['ok'] = not out['has_errors']", + " except Exception as e:", + " ok = bool(unreal.BlueprintEditorLibrary.compile_blueprint(bp))", + " out = {'ok': ok, 'bp_path': " + pyStr(bp_path) + ", 'fallback': True, 'error': str(e)}", + "sd = unreal.Paths.project_saved_dir() + 'MCP/'", + "os.makedirs(sd, exist_ok=True)", + "open(sd + 'last.json', 'w', encoding='utf-8').write(json.dumps(out))", + `open(sd + '${rid}.json', 'w', encoding='utf-8').write(json.dumps(out))`, + "unreal.log('[MCP-COMPILE] ' + json.dumps(out)[:1500])", + ].join("\n"); + const sent = await executePythonInUE(py); + if (!sent.ok) return errResult(sent.error); + return await readRidResult(rid); +} + +async function handleReadGraph({ bp_path, function: fn }) { + const rid = makeRid(); + const payload = { __op: "read", bp_path: bp_path || "", function: fn || "EventGraph", __rid: rid }; + const cmd = buildApplyCommand(payload); + const sent = await executePythonInUE(cmd); + if (!sent.ok) return errResult(sent.error || "UE call failed"); + return await readRidResult(rid); +} + +async function handlePresetOp(args) { + if (!args.name) return errResult("preset_op: name required"); + const rid = makeRid(); + const payload = JSON.stringify({ ...args, __rid: rid }); + const py = [ + "import sys", + `_d = ${pyStr(UE_SIDE_DIR_FOR_JS)}`, + "sys.path.insert(0, _d) if _d not in sys.path else None", + "import importlib, presets", + "importlib.reload(presets)", + `_ = presets.entrypoint(${pyStr(payload)})`, + ].join("\n"); + const sent = await executePythonInUE(py); + if (!sent.ok) return errResult(sent.error || "UE call failed"); + return await readRidResult(rid); +} + +function makeRid() { + return "r-" + Math.random().toString(36).slice(2, 10) + "-" + Date.now().toString(36); +} + +async function handleModuleOp(moduleName, args) { + // Dispatches to ue_side/{moduleName}.py — same pattern as apply_graph, but + // each module owns its own op-table. Keeps server.js small as we add more + // categories (assets, level, umg, niagara…). + if (!args || !args.op) return errResult(`${moduleName}_op: op required`); + const rid = makeRid(); + const payloadObj = { ...args, __rid: rid }; + const payload = JSON.stringify(payloadObj); + const py = [ + "import sys, json", + `_d = ${pyStr(UE_SIDE_DIR_FOR_JS)}`, + "sys.path.insert(0, _d) if _d not in sys.path else None", + `import importlib, ${moduleName}`, + `importlib.reload(${moduleName})`, + `_ = ${moduleName}.entrypoint(${pyStr(payload)})`, + ].join("\n"); + const sent = await executePythonInUE(py); + if (!sent.ok) return errResult(sent.error || "UE call failed"); + return await readRidResult(rid); +} + +async function readRidResult(rid, projectDir = DEFAULT_PROJECT_DIR) { + const fs = await import("node:fs/promises"); + const path = `${projectDir}/Saved/MCP/${rid}.json`; + try { + const txt = await fs.readFile(path, "utf8"); + // Best-effort cleanup + fs.unlink(path).catch(() => {}); + return okResult(JSON.parse(txt)); + } catch (e) { + return errResult(`couldn't read ${path}: ${e.message}`); + } +} + +async function readLastResult(projectDir = DEFAULT_PROJECT_DIR) { + const fs = await import("node:fs/promises"); + try { + const txt = await fs.readFile(`${projectDir}/Saved/MCP/last.json`, "utf8"); + return okResult(JSON.parse(txt)); + } catch (e) { + return errResult(`couldn't read ${projectDir}/Saved/MCP/last.json: ${e.message}`); + } +} + +async function handlePing() { + const r = await ping(); + return okResult(r); +} + +async function handleGetActiveBp() { + const py = [ + "import json, unreal, os", + "out = {'ok': False}", + "try:", + " sel = list(unreal.EditorUtilityLibrary.get_selected_assets() or [])", + " bps = [a for a in sel if isinstance(a, unreal.Blueprint)]", + " if len(bps) == 1:", + " out = {'ok': True, 'path': bps[0].get_path_name().split('.')[0], 'source': 'content_browser_selection'}", + " elif len(bps) > 1:", + " out = {'ok': False, 'error': f'{len(bps)} blueprints selected'}", + " else:", + " out = {'ok': False, 'error': 'no Blueprint selected in Content Browser', 'selected_assets': [a.get_path_name() for a in sel]}", + "except Exception as e:", + " out = {'ok': False, 'error': str(e)}", + "unreal.log('[MCP-ACTIVE-BP] ' + json.dumps(out))", + "sd = unreal.Paths.project_saved_dir() + 'MCP/'", + "os.makedirs(sd, exist_ok=True)", + "open(sd + 'active_bp.json', 'w', encoding='utf-8').write(json.dumps(out))", + ].join("\n"); + const sent = await executePythonInUE(py); + if (!sent.ok) return errResult(sent.error); + // Read the file back. + const proj = DEFAULT_PROJECT_DIR; + const fs = await import("node:fs/promises"); + try { + const txt = await fs.readFile(`${proj}/Saved/MCP/active_bp.json`, "utf8"); + return okResult({ rc_response: sent.response, ...JSON.parse(txt) }); + } catch (e) { + return okResult({ rc_response: sent.response, note: "wrote command but couldn't read active_bp.json — check Output Log for [MCP-ACTIVE-BP]" }); + } +} + +async function handleReadLastResult({ project_dir }) { + const proj = project_dir || DEFAULT_PROJECT_DIR; + const fs = await import("node:fs/promises"); + try { + const txt = await fs.readFile(`${proj}/Saved/MCP/last.json`, "utf8"); + return okResult(JSON.parse(txt)); + } catch (e) { + return errResult(`couldn't read ${proj}/Saved/MCP/last.json: ${e.message}`); + } +} + +async function handleReadPythonLog({ project_dir, log_name, max_lines, errors_only }) { + const proj = project_dir || DEFAULT_PROJECT_DIR; + const name = log_name || PP.logName(); + const max = Math.max(1, Math.min(2000, max_lines || 80)); + const fs = await import("node:fs/promises"); + let raw; + try { + raw = await fs.readFile(`${proj}/Saved/Logs/${name}`, "utf8"); + } catch (e) { + return errResult(`couldn't read ${proj}/Saved/Logs/${name}: ${e.message}`); + } + const all = raw.split(/\r?\n/); + + // Group Python tracebacks: a "Traceback (most recent call last):" line plus + // following indented frames are kept as one block tied to the first line. + const matched = []; + let inTraceback = false; + for (let i = 0; i < all.length; i++) { + const line = all[i]; + const isPy = /LogPython:/i.test(line); + const isTbStart = /Traceback \(most recent call last\)/.test(line); + const isTbCont = inTraceback && (/^\s+(File |\^|\.\.\.)/.test(line) || /Error/.test(line)); + if (isTbStart) inTraceback = true; + if (isPy || inTraceback) { + if (errors_only) { + if (/Error|Traceback|Warning|exception/i.test(line) || isTbCont) matched.push(line); + } else { + matched.push(line); + } + } + if (inTraceback && !isTbCont && !isTbStart && !/^\s/.test(line)) inTraceback = false; + } + + const tail = matched.slice(-max); + return okResult({ + log_path: `${proj}/Saved/Logs/${name}`, + total_python_lines: matched.length, + returned: tail.length, + errors_only: !!errors_only, + lines: tail, + }); +} + +async function handleBuildOp(args) { + const op = args.op; + try { + switch (op) { + case "save_all": return okResult(await saveAll()); + case "close_editor": return okResult(await quitEditor()); + case "build": return okResult(await runBuild(args)); + case "launch_editor": return okResult(launchEditor(args)); + case "full_rebuild": return okResult(await fullRebuild(args)); + default: return errResult(`build_op: unknown op '${op}'`); + } + } catch (e) { + return errResult(`build_op ${op} threw: ${e.stack || e.message}`); + } +} + +function pyStr(s) { return "'" + String(s).replace(/\\/g, "\\\\").replace(/'/g, "\\'") + "'"; } +// If a module result carries a `png_path`, read that PNG and attach it INLINE as +// an image content block so the agent SEES the render instead of just a path. +// No-op for results without a png_path (most ops), so it's safe to wrap freely. +async function attachInlineImage(result) { + try { + if (!result || !Array.isArray(result.content)) return result; + const textBlock = result.content.find((c) => c.type === "text"); + if (!textBlock || typeof textBlock.text !== "string") return result; + let obj; + try { obj = JSON.parse(textBlock.text); } catch { return result; } + const p = obj && obj.png_path; + if (!p) return result; + const fs = await import("node:fs/promises"); + const stat = await fs.stat(p).catch(() => null); + if (!stat || stat.size === 0 || stat.size > 8 * 1024 * 1024) return result; // skip missing/huge + const buf = await fs.readFile(p); + result.content.push({ type: "image", data: buf.toString("base64"), mimeType: "image/png" }); + } catch { /* best-effort — never fail the call over the preview */ } + return result; +} + +function okResult(obj) { return { content: [{ type: "text", text: JSON.stringify(obj, null, 2) }] }; } +function errResult(msg) { return { content: [{ type: "text", text: msg }], isError: true }; } + +await server.connect(new StdioServerTransport()); diff --git a/src/ueBridge.js b/src/ueBridge.js new file mode 100644 index 0000000..0747dde --- /dev/null +++ b/src/ueBridge.js @@ -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); + } +} diff --git a/test/insights.test.js b/test/insights.test.js new file mode 100644 index 0000000..e0d4014 --- /dev/null +++ b/test/insights.test.js @@ -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); +}); diff --git a/test/mockUE.test.js b/test/mockUE.test.js new file mode 100644 index 0000000..779ff05 --- /dev/null +++ b/test/mockUE.test.js @@ -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/); +}); diff --git a/test/nodeSpec.test.js b/test/nodeSpec.test.js new file mode 100644 index 0000000..49409c6 --- /dev/null +++ b/test/nodeSpec.test.js @@ -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}`); + } +}); diff --git a/test/pythonGen.test.js b/test/pythonGen.test.js new file mode 100644 index 0000000..231b466 --- /dev/null +++ b/test/pythonGen.test.js @@ -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); +}); diff --git a/test/server.test.js b/test/server.test.js new file mode 100644 index 0000000..9035940 --- /dev/null +++ b/test/server.test.js @@ -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(); } +}); diff --git a/ue_side/ai.py b/ue_side/ai.py new file mode 100644 index 0000000..adca55a --- /dev/null +++ b/ue_side/ai.py @@ -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 diff --git a/ue_side/animation.py b/ue_side/animation.py new file mode 100644 index 0000000..d661c68 --- /dev/null +++ b/ue_side/animation.py @@ -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 diff --git a/ue_side/apply_graph.py b/ue_side/apply_graph.py new file mode 100644 index 0000000..e2044fb --- /dev/null +++ b/ue_side/apply_graph.py @@ -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 /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:" -> 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 diff --git a/ue_side/assets.py b/ue_side/assets.py new file mode 100644 index 0000000..a86c3ad --- /dev/null +++ b/ue_side/assets.py @@ -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 diff --git a/ue_side/audio.py b/ue_side/audio.py new file mode 100644 index 0000000..5fea2d5 --- /dev/null +++ b/ue_side/audio.py @@ -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 diff --git a/ue_side/capture.py b/ue_side/capture.py new file mode 100644 index 0000000..921a9ab --- /dev/null +++ b/ue_side/capture.py @@ -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// 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 diff --git a/ue_side/console.py b/ue_side/console.py new file mode 100644 index 0000000..80b5885 --- /dev/null +++ b/ue_side/console.py @@ -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 diff --git a/ue_side/cpp_scaffold.py b/ue_side/cpp_scaffold.py new file mode 100644 index 0000000..8a6c681 --- /dev/null +++ b/ue_side/cpp_scaffold.py @@ -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. .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 diff --git a/ue_side/cull.py b/ue_side/cull.py new file mode 100644 index 0000000..fc479a2 --- /dev/null +++ b/ue_side/cull.py @@ -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 diff --git a/ue_side/discover.py b/ue_side/discover.py new file mode 100644 index 0000000..91762a2 --- /dev/null +++ b/ue_side/discover.py @@ -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 diff --git a/ue_side/edit_ops.py b/ue_side/edit_ops.py new file mode 100644 index 0000000..41127d8 --- /dev/null +++ b/ue_side/edit_ops.py @@ -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 diff --git a/ue_side/graph_layout.py b/ue_side/graph_layout.py new file mode 100644 index 0000000..b7c47ad --- /dev/null +++ b/ue_side/graph_layout.py @@ -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 diff --git a/ue_side/input.py b/ue_side/input.py new file mode 100644 index 0000000..8a9c372 --- /dev/null +++ b/ue_side/input.py @@ -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 diff --git a/ue_side/level.py b/ue_side/level.py new file mode 100644 index 0000000..4c9d472 --- /dev/null +++ b/ue_side/level.py @@ -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 diff --git a/ue_side/live_coding.py b/ue_side/live_coding.py new file mode 100644 index 0000000..7539a0e --- /dev/null +++ b/ue_side/live_coding.py @@ -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/.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\\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 diff --git a/ue_side/materials.py b/ue_side/materials.py new file mode 100644 index 0000000..8cc854a --- /dev/null +++ b/ue_side/materials.py @@ -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 diff --git a/ue_side/mesh.py b/ue_side/mesh.py new file mode 100644 index 0000000..8c3c667 --- /dev/null +++ b/ue_side/mesh.py @@ -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 diff --git a/ue_side/network.py b/ue_side/network.py new file mode 100644 index 0000000..45642e9 --- /dev/null +++ b/ue_side/network.py @@ -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 diff --git a/ue_side/niagara.py b/ue_side/niagara.py new file mode 100644 index 0000000..5cfe046 --- /dev/null +++ b/ue_side/niagara.py @@ -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 diff --git a/ue_side/pcg.py b/ue_side/pcg.py new file mode 100644 index 0000000..f252165 --- /dev/null +++ b/ue_side/pcg.py @@ -0,0 +1,1237 @@ +"""PCG (Procedural Content Generation) graph MCP ops. + +Pure-Python worker — unlike the Voxel worker, PCG's data model +(UPCGGraph / UPCGNode / UPCGSettings) is engine-exposed to Python, so no C++ +shim is required. + +"All workers" = every UPCGSettings subclass bound into the `unreal` module. +This is enumerated dynamically (see `_settings_registry`), which means engine +PCG nodes AND PCGExtendedToolkit (PCGEx) nodes are both supported automatically +without a hardcoded table — any plugin that ships UPCGSettings subclasses gets +picked up for free. + +Node identity: PCG nodes have no stable public GUID exposed to Python, so a node +is referenced by: + * "input" / "output" -> the graph's special I/O nodes + * an integer index -> position in get_nodes() + * a UObject name (node.get_name()) + * a settings class short name / title (first match) + +Ops (authoring): + api {} introspect live PCG reflection (method names, etc.) + create_graph {path} new empty UPCGGraph asset + list_node_types {filter?, limit?} catalogue of spawnable workers (all UPCGSettings subclasses) + read_graph {asset_path} dump nodes + pins + edges + add_node {asset_path, node, x?, y?, title?} spawn a worker (node = class name/path/title) + delete_node {asset_path, node} + connect {asset_path, from:[node,pin?], to:[node,pin?]} + disconnect {asset_path, from:[node,pin?], to:[node,pin?]} + list_node_properties {asset_path, node} editor properties of a node's settings + set_node_property {asset_path, node, property, value} + move_nodes {asset_path, moves:[{node,x,y}]} best-effort editor positions + save {asset_path} + open_asset {asset_path} + +Ops (declarative authoring + layout): + apply_graph {asset_path, nodes:[{id,type,x?,y?,title?,properties?}], edges:[{from:[id,pin?],to:[id,pin?]}], outputs?:[{from:[id,pin?],pin?}], clear_existing?, auto_layout?, save?} + build a whole graph in one call (auto-laid-out by default) + auto_layout/tidy_graph/layout {asset_path} re-flow existing nodes into edge-depth columns + +Ops (parameters + subgraph): + list_params {asset_path} dump graph UserParameters bag (read-only — UE blocks Python editing) + set_subgraph {asset_path, node, subgraph} point a Subgraph node at a target PCG graph asset + +Ops (level / runtime): + spawn_volume {graph?, location?, rotation?, scale?, name?, generate?} spawn a PCG Volume actor, assign graph, generate + set_actor_graph {actor, graph, generate?} assign a graph to an existing actor's PCGComponent + generate {actor, force?} (re)generate a PCG actor's component + cleanup {actor} clear an actor's generated content + list_components {} level actors carrying a PCGComponent + their graph/state + +Ops (presets): + scaffold {path|asset_path, template, create?, save?} build a working graph from a named template + (template='__list__' lists: surface_scatter, points_grid_spawn, density_filter_scatter) +""" +from __future__ import annotations + +import json +import os +import traceback + +try: + import unreal # type: ignore +except ImportError: + unreal = None + +try: + import graph_layout # shared deterministic layout helper +except Exception: # noqa: BLE001 - layout is optional; ops degrade gracefully + graph_layout = None + + +# --------------------------------------------------------------------------- # +# low-level helpers +# --------------------------------------------------------------------------- # +def _pcg_base(): + base = getattr(unreal, "PCGSettings", None) + if base is None: + raise RuntimeError( + "unreal.PCGSettings not found - is the PCG plugin enabled and the " + "Python API loaded? (Project uses PCGExtendedToolkit which depends on it.)" + ) + return base + + +def _load_graph(path: str): + asset = unreal.EditorAssetLibrary.load_asset(path) + if asset is None: + raise RuntimeError(f"asset not found: {path!r}") + if not isinstance(asset, unreal.PCGGraph): + raise RuntimeError( + f"asset is not a UPCGGraph: {path!r} ({asset.get_class().get_name()})") + return asset + + +def _save(asset): + try: + unreal.EditorAssetLibrary.save_loaded_asset(asset) + except Exception: + pass + + +def _short(cls) -> str: + """'PCGDensityFilterSettings' from a class object. Note: a bound unreal + class object's get_name()/get_path_name() are unbound (need an instance), + so use the Python type __name__.""" + name = getattr(cls, "__name__", None) + if name: + return name + try: + return cls.get_name() + except Exception: + return str(cls) + + +def _class_path(cls) -> str: + """'/Script/PCG.PCGSurfaceSamplerSettings' — derived via the CDO because a + bound class object's get_path_name() is unbound in the unreal Python API.""" + try: + return unreal.get_default_object(cls).get_class().get_path_name() + except Exception: + return _short(cls) + + +def _title_for(cls) -> str: + """Best-effort human title from the settings CDO.""" + try: + cdo = unreal.get_default_object(cls) + for getter in ("get_default_node_title", "get_default_node_name"): + fn = getattr(cdo, getter, None) + if fn: + try: + val = str(fn()) + if val: + return val + except Exception: + pass + except Exception: + pass + name = _short(cls) + # strip the conventional PCG/PCGEx prefix + Settings suffix for readability + base = name[:-8] if name.endswith("Settings") else name + return base + + +# --------------------------------------------------------------------------- # +# node-type catalogue ("all workers") +# --------------------------------------------------------------------------- # +def _settings_registry() -> dict: + """{class_object: {name, title, category, plugin}} for every bound + UPCGSettings subclass. Dynamic — picks up PCG + PCGEx + any plugin.""" + base = _pcg_base() + out = {} + for attr in dir(unreal): + obj = getattr(unreal, attr, None) + try: + if not isinstance(obj, type) or obj is base: + continue + if not issubclass(obj, base): + continue + except TypeError: + continue + name = _short(obj) + plugin = "PCGEx" if name.startswith("PCGEx") else "PCG" + out[obj] = {"name": name, "title": _title_for(obj), "plugin": plugin} + return out + + +def _resolve_settings_class(ref: str): + """ref -> settings UClass. Accepts short name, /Script path, or title.""" + ref = (ref or "").strip() + if not ref: + raise RuntimeError("node type ref is empty") + # 1) explicit /Script path + if ref.startswith("/Script/"): + cls = unreal.load_class(None, ref) + if cls is not None: + return cls + # 2) direct attribute on unreal module (exact or with Settings suffix) + for cand in (ref, ref + "Settings", "PCG" + ref, "PCG" + ref + "Settings", + "PCGEx" + ref, "PCGEx" + ref + "Settings"): + obj = getattr(unreal, cand, None) + if isinstance(obj, type): + return obj + # 3) registry lookup by name / title (case-insensitive) + low = ref.lower() + reg = _settings_registry() + for cls, meta in reg.items(): + if meta["name"].lower() == low or meta["title"].lower() == low: + return cls + for cls, meta in reg.items(): # loose contains match + if low in meta["name"].lower() or low in meta["title"].lower(): + return cls + raise RuntimeError(f"unknown PCG node type: {ref!r} (try list_node_types)") + + +# --------------------------------------------------------------------------- # +# node referencing within a graph +# --------------------------------------------------------------------------- # +def _graph_nodes(graph) -> list: + # UPCGGraph exposes 'nodes' as a property (no get_nodes()). + for src in ("nodes",): + try: + val = graph.get_editor_property(src) + if val is not None: + return list(val) + except Exception: + pass + fn = getattr(graph, "get_nodes", None) + if callable(fn): + try: + return list(fn()) + except Exception: + pass + return [] + + +def _io_node(graph, which: str): + fn = getattr(graph, "get_input_node" if which == "input" else "get_output_node", None) + return fn() if fn else None + + +def _resolve_node(graph, ref): + """ref -> UPCGNode. Accepts 'input'/'output', int index, UObject name, + or settings class short-name/title (first match).""" + if ref is None: + raise RuntimeError("node ref is None") + if isinstance(ref, str) and ref.lower() in ("input", "output"): + node = _io_node(graph, ref.lower()) + if node is None: + raise RuntimeError(f"graph has no {ref} node") + return node + nodes = _graph_nodes(graph) + # integer index + try: + idx = int(ref) + if 0 <= idx < len(nodes): + return nodes[idx] + except (TypeError, ValueError): + pass + low = str(ref).lower() + # exact UObject name + for n in nodes: + try: + if n.get_name().lower() == low: + return n + except Exception: + pass + # settings class short name / title + for n in nodes: + meta = _node_settings_meta(n) + if meta and (meta["class"].lower() == low or meta["title"].lower() == low): + return n + for n in nodes: # loose contains + meta = _node_settings_meta(n) + if meta and (low in meta["class"].lower() or low in meta["title"].lower()): + return n + raise RuntimeError(f"node not found in graph: {ref!r} (try read_graph)") + + +def _node_settings(node): + for getter in ("get_settings", "default_settings"): + fn = getattr(node, getter, None) + if callable(fn): + try: + s = fn() + if s is not None: + return s + except Exception: + pass + # property fallbacks + for prop in ("settings_interface", "default_settings"): + try: + s = node.get_editor_property(prop) + if s is not None: + # settings_interface wraps the settings + inner = getattr(s, "get_settings", None) + return inner() if callable(inner) else s + except Exception: + pass + return None + + +def _node_settings_meta(node): + s = _node_settings(node) + if s is None: + return None + cls = s.get_class() + return {"class": _short(cls), "title": _title_for(cls)} + + +def _pins(node, which: str) -> list: + # UPCGNode exposes 'input_pins'/'output_pins' as properties. + prop = "input_pins" if which == "in" else "output_pins" + try: + val = node.get_editor_property(prop) + if val is not None: + return list(val) + except Exception: + pass + fn = getattr(node, "get_input_pins" if which == "in" else "get_output_pins", None) + if callable(fn): + try: + return list(fn()) + except Exception: + pass + return [] + + +def _pin_label(pin) -> str: + try: + props = pin.get_editor_property("properties") + return str(props.get_editor_property("label")) + except Exception: + pass + for getter in ("get_pin_label", "get_name"): + fn = getattr(pin, getter, None) + if callable(fn): + try: + return str(fn()) + except Exception: + pass + return "" + + +# --------------------------------------------------------------------------- # +# ops +# --------------------------------------------------------------------------- # +def api(args: dict) -> dict: + """Dump live reflection so callers can confirm exposed method names. Handy + because PCG Python signatures shift between engine versions.""" + def members(name): + cls = getattr(unreal, name, None) + if cls is None: + return None + return sorted(m for m in dir(cls) if not m.startswith("_")) + return { + "ok": True, + "PCGGraph": members("PCGGraph"), + "PCGNode": members("PCGNode"), + "PCGSettings": members("PCGSettings"), + "PCGPin": members("PCGPin"), + "settings_class_count": len(_settings_registry()), + } + + +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/PCG/MyGraph)") + tools = unreal.AssetToolsHelpers.get_asset_tools() + factory = None + for fname in ("PCGGraphFactory", "PCGGraphFactory_New"): + fcls = getattr(unreal, fname, None) + if fcls is not None: + factory = fcls() + break + asset = tools.create_asset(name, pkg_dir, unreal.PCGGraph, factory) + if asset is None: + raise RuntimeError(f"failed to create PCG graph at {path}") + _save(asset) + return {"ok": True, "asset": asset.get_path_name()} + + +def list_node_types(args: dict) -> dict: + flt = str(args.get("filter") or "").lower() + limit = int(args.get("limit") or 0) + reg = _settings_registry() + items = [] + for cls, meta in reg.items(): + if flt and flt not in meta["name"].lower() and flt not in meta["title"].lower(): + continue + items.append({ + "name": meta["name"], + "title": meta["title"], + "plugin": meta["plugin"], + "path": _class_path(cls), + }) + items.sort(key=lambda d: (d["plugin"], d["name"])) + total = len(items) + if limit > 0: + items = items[:limit] + return {"ok": True, "count": total, "returned": len(items), "node_types": items} + + +def read_graph(args: dict) -> dict: + graph = _load_graph(args["asset_path"]) + nodes = _graph_nodes(graph) + out_nodes = [] + name_to_idx = {} + for i, n in enumerate(nodes): + try: + uname = n.get_name() + except Exception: + uname = f"node_{i}" + name_to_idx[uname] = i + meta = _node_settings_meta(n) or {"class": "?", "title": "?"} + out_nodes.append({ + "index": i, + "name": uname, + "class": meta["class"], + "title": meta["title"], + "in_pins": [_pin_label(p) for p in _pins(n, "in")], + "out_pins": [_pin_label(p) for p in _pins(n, "out")], + }) + + # special I/O nodes (not always in get_nodes) + io = {} + for which in ("input", "output"): + node = _io_node(graph, which) + if node is not None: + io[which] = { + "in_pins": [_pin_label(p) for p in _pins(node, "in")], + "out_pins": [_pin_label(p) for p in _pins(node, "out")], + } + + # edges: the same UPCGEdge object sits in both the source output-pin's and + # the destination input-pin's `edges` array. PCGEdge's InputPin/OutputPin + # aren't reflection-accessible, so we pair the two sides by edge identity. + all_nodes = list(nodes) + for which in ("input", "output"): + n = _io_node(graph, which) + if n is not None and n not in all_nodes: + all_nodes.append(n) + edges = _collect_edges(all_nodes) + return {"ok": True, "asset": graph.get_path_name(), + "node_count": len(out_nodes), "nodes": out_nodes, + "io": io, "edges": edges} + + +def _pin_edge_objs(pin) -> list: + try: + return list(pin.get_editor_property("edges") or []) + except Exception: + return [] + + +def _obj_key(obj) -> str: + for getter in ("get_path_name", "get_name"): + fn = getattr(obj, getter, None) + if callable(fn): + try: + return str(fn()) + except Exception: + pass + return str(id(obj)) + + +def _is_output_pin(pin, fallback_which: str) -> bool: + fn = getattr(pin, "is_output_pin", None) + if callable(fn): + try: + return bool(fn()) + except Exception: + pass + return fallback_which == "out" + + +def _collect_edges(all_nodes) -> list: + """Rebuild edges by pairing the shared UPCGEdge object found on both the + source output pin and the destination input pin.""" + by_key = {} + for n in all_nodes: + try: + nname = n.get_name() + except Exception: + continue + for which in ("in", "out"): + for pin in _pins(n, which): + label = _pin_label(pin) + side = "out" if _is_output_pin(pin, which) else "in" + for e in _pin_edge_objs(pin): + rec = by_key.setdefault(_obj_key(e), {}) + rec[side] = {"node": nname, "pin": label} + edges = [] + for rec in by_key.values(): + if "out" in rec and "in" in rec: + edges.append({ + "from_node": rec["out"]["node"], "from_pin": rec["out"]["pin"], + "to_node": rec["in"]["node"], "to_pin": rec["in"]["pin"], + }) + else: # only one side seen (endpoint outside enumerated nodes) + known = rec.get("out") or rec.get("in") + edges.append({"from_node": known["node"], "from_pin": known["pin"], + "to_node": "?", "to_pin": "?", "partial": True} + if known else {"partial": True}) + return edges + + +def add_node(args: dict) -> dict: + graph = _load_graph(args["asset_path"]) + cls = _resolve_settings_class(str(args.get("node") or args.get("type") or "")) + node, _settings = _add_node_inner(graph, cls) + if node is None: + raise RuntimeError(f"failed to add node {_short(cls)}") + _apply_position(node, args) + title = args.get("title") + if title: + _set_node_title(node, str(title)) + _save(graph) + return {"ok": True, "name": node.get_name(), "class": _short(cls), + "in_pins": [_pin_label(p) for p in _pins(node, "in")], + "out_pins": [_pin_label(p) for p in _pins(node, "out")]} + + +def _apply_position(node, args): + if "x" not in args and "y" not in args: + return + x, y = float(args.get("x", 0)), float(args.get("y", 0)) + fn = getattr(node, "set_node_position", None) + if callable(fn): + # UPCGNode.set_node_position takes two floats (its getter returns an + # (x, y) tuple). Fall back to a single Vector2D for other node types. + for call in (lambda: fn(x, y), lambda: fn(unreal.Vector2D(x, y))): + try: + call() + return + except Exception: + continue + for px, py in (("node_pos_x", "node_pos_y"), ("position_x", "position_y")): + try: + node.set_editor_property(px, int(x)) + node.set_editor_property(py, int(y)) + return + except Exception: + continue + + +def _set_node_title(node, title): + for prop in ("node_title", "node_comment", "author_generated_node_name"): + try: + node.set_editor_property(prop, title) + return + except Exception: + continue + + +def delete_node(args: dict) -> dict: + graph = _load_graph(args["asset_path"]) + node = _resolve_node(graph, args.get("node")) + name = node.get_name() + fn = getattr(graph, "remove_node", None) + if not callable(fn): + raise RuntimeError("UPCGGraph.remove_node not exposed to Python") + fn(node) + _save(graph) + return {"ok": True, "removed": name} + + +def _pin_or_default(node, which: str, label): + """Resolve a pin label; default to the single/first pin when omitted.""" + pins = _pins(node, which) + if not pins: + raise RuntimeError(f"node {node.get_name()!r} has no {which} pins") + if label in (None, ""): + return _pin_label(pins[0]) + low = str(label).lower() + for p in pins: + if _pin_label(p).lower() == low: + return _pin_label(p) + # not found -> surface available labels + raise RuntimeError( + f"{which} pin {label!r} not on {node.get_name()!r}; " + f"available: {[_pin_label(p) for p in pins]}") + + +def connect(args: dict) -> dict: + graph = _load_graph(args["asset_path"]) + f = args.get("from") or [] + t = args.get("to") or [] + from_node = _resolve_node(graph, f[0]) + to_node = _resolve_node(graph, t[0]) + from_pin = _pin_or_default(from_node, "out", f[1] if len(f) > 1 else None) + to_pin = _pin_or_default(to_node, "in", t[1] if len(t) > 1 else None) + fn = (getattr(graph, "add_labeled_edge", None) + or getattr(graph, "add_edge", None)) + if not callable(fn): + raise RuntimeError("UPCGGraph exposes no add_labeled_edge/add_edge") + fn(from_node, unreal.Name(from_pin), to_node, unreal.Name(to_pin)) + _save(graph) + return {"ok": True, "from": [from_node.get_name(), from_pin], + "to": [to_node.get_name(), to_pin]} + + +def disconnect(args: dict) -> dict: + graph = _load_graph(args["asset_path"]) + f = args.get("from") or [] + t = args.get("to") or [] + from_node = _resolve_node(graph, f[0]) + to_node = _resolve_node(graph, t[0]) + from_pin = _pin_or_default(from_node, "out", f[1] if len(f) > 1 else None) + to_pin = _pin_or_default(to_node, "in", t[1] if len(t) > 1 else None) + fn = getattr(graph, "remove_edge", None) + if not callable(fn): + raise RuntimeError("UPCGGraph.remove_edge not exposed to Python") + ok = bool(fn(from_node, unreal.Name(from_pin), to_node, unreal.Name(to_pin))) + _save(graph) + return {"ok": ok, "from": [from_node.get_name(), from_pin], + "to": [to_node.get_name(), to_pin]} + + +def list_node_properties(args: dict) -> dict: + graph = _load_graph(args["asset_path"]) + node = _resolve_node(graph, args.get("node")) + settings = _node_settings(node) + if settings is None: + raise RuntimeError(f"node {node.get_name()!r} has no settings object") + # Classify method vs editor-property at the CLASS level: getattr on the + # type returns the descriptor without invoking the getter, so we never + # trigger "property is protected and cannot be read" on the instance. + cls = type(settings) + props = [] + for m in dir(settings): + if m.startswith("_"): + continue + try: + attr = getattr(cls, m, None) + except Exception: + attr = None + if callable(attr): + continue # it's a method, not a property + props.append(m) + return {"ok": True, "node": node.get_name(), + "settings_class": _short(settings.get_class()), "properties": props} + + +def _num_list(value): + """Parse value (list or JSON string) into a list of floats, else None.""" + if isinstance(value, str): + try: + value = json.loads(value.strip()) + except Exception: + return None + if isinstance(value, list) and value and all(isinstance(n, (int, float)) for n in value): + return [float(n) for n in value] + return None + + +def _coerce_value(value): + """MCP args may arrive stringified (the `value` field is untyped). Parse + JSON-ish strings to native types, and turn [x,y,z] number lists into the + matching unreal vector so struct properties (rotation/scale/offset) work.""" + if isinstance(value, str): + try: + value = json.loads(value.strip()) + except Exception: + return value # plain string (enum name, attribute name, etc.) + if isinstance(value, list) and value and all(isinstance(n, (int, float)) for n in value): + nums = [float(n) for n in value] + if len(nums) == 2: + return unreal.Vector2D(*nums) + if len(nums) == 3: + return unreal.Vector(*nums) + if len(nums) == 4: + return unreal.Vector4(*nums) + return value + + +def set_node_property(args: dict) -> dict: + graph = _load_graph(args["asset_path"]) + node = _resolve_node(graph, args.get("node")) + settings = _node_settings(node) + if settings is None: + raise RuntimeError(f"node {node.get_name()!r} has no settings object") + prop = str(args["property"]) + raw = args.get("value") + value = _coerce_value(raw) + used = value + try: + settings.set_editor_property(prop, value) + except TypeError: + # A 3-number list is ambiguous: Vector vs Rotator (pitch,yaw,roll). + # _coerce_value picked Vector; retry the other struct kinds. + nums = _num_list(raw) + ok = False + if nums and len(nums) == 3: + for ctor in (unreal.Rotator, unreal.Vector): + try: + used = ctor(*nums) + settings.set_editor_property(prop, used) + ok = True + break + except Exception: + continue + if not ok: + raise + _save(graph) + return {"ok": True, "node": node.get_name(), "property": prop, "value": str(used)} + + +def move_nodes(args: dict) -> dict: + graph = _load_graph(args["asset_path"]) + moved = 0 + for m in args.get("moves") or []: + try: + node = _resolve_node(graph, m["node"]) + _apply_position(node, m) + moved += 1 + except Exception: + continue + if moved: + _save(graph) + return {"ok": moved > 0, "moved": moved} + + +def save(args: dict) -> dict: + graph = _load_graph(args["asset_path"]) + _save(graph) + return {"ok": True, "asset": graph.get_path_name()} + + +def open_asset(args: dict) -> dict: + graph = _load_graph(args["asset_path"]) + sub = unreal.get_editor_subsystem(unreal.AssetEditorSubsystem) + sub.open_editor_for_assets([graph]) + return {"ok": True, "asset": graph.get_path_name()} + + +def _set_one_property(settings, prop: str, raw): + """Coerce + assign one editor property on a settings object. Mirrors + set_node_property's logic so apply_graph and set_node_property agree.""" + value = _coerce_value(raw) + try: + settings.set_editor_property(prop, value) + return value + except TypeError: + nums = _num_list(raw) + if nums and len(nums) == 3: + for ctor in (unreal.Rotator, unreal.Vector): + try: + used = ctor(*nums) + settings.set_editor_property(prop, used) + return used + except Exception: + continue + raise + + +# --------------------------------------------------------------------------- # +# declarative whole-graph authoring +# --------------------------------------------------------------------------- # +def apply_graph(args: dict) -> dict: + """Build a graph in one call. Spec: + nodes: [{id, type|class, x?, y?, title?, properties?:{}}] + edges: [{from:[id, pin?], to:[id, pin?]}] + outputs: [{from:[id, pin?], pin?}] -> wire id to the graph Output node + clear_existing?, auto_layout?(default True), save?(default True) + 'id' is a caller-chosen local handle reused by edges/outputs. 'input'/'output' + refer to the graph's I/O nodes. Returns {spawned, edges, errors, warnings}.""" + graph = _load_graph(args["asset_path"]) + result = {"ok": False, "asset": graph.get_path_name(), + "spawned": {}, "edges": [], "errors": [], "warnings": []} + + if args.get("clear_existing"): + for n in list(_graph_nodes(graph)): + try: + graph.remove_node(n) + except Exception as exc: # noqa: BLE001 + result["warnings"].append(f"clear: {exc}") + + raw_nodes = list(args.get("nodes") or []) + raw_edges = list(args.get("edges") or []) + + # deterministic layout: honour caller x/y, snap into readable columns + specs = raw_nodes + if args.get("auto_layout", True) and graph_layout is not None: + lay_nodes, lay_edges = [], [] + for spec in raw_nodes: + x = spec.get("x", (spec.get("position") or [0, 0])[0] if spec.get("position") else 0) + y = spec.get("y", (spec.get("position") or [0, 0])[1] if spec.get("position") else 0) + lay_nodes.append({"id": spec.get("id"), "class": spec.get("type") or spec.get("class") or "", + "position": [float(x), float(y)]}) + for e in raw_edges: + try: + lay_edges.append({"from": [e["from"][0]], "to": [e["to"][0]]}) + except Exception: + pass + laid = graph_layout.normalize_specs(lay_nodes, lay_edges, domain="pcg") + pos_by_id = {s.get("id"): s.get("position") for s in laid} + specs = [] + for spec in raw_nodes: + s = dict(spec) + p = pos_by_id.get(spec.get("id")) + if p: + s["x"], s["y"] = p[0], p[1] + specs.append(s) + + local_map = {} + for spec in specs: + local_id = spec.get("id") + try: + ref = str(spec.get("type") or spec.get("class") or "") + cls = _resolve_settings_class(ref) + node, settings = _add_node_inner(graph, cls) + if settings is None: + settings = _node_settings(node) + _apply_position(node, spec) + if spec.get("title"): + _set_node_title(node, str(spec["title"])) + applied = {} + for pname, pval in (spec.get("properties") or {}).items(): + try: + applied[pname] = str(_set_one_property(settings, pname, pval)) + except Exception as exc: # noqa: BLE001 + result["warnings"].append(f"node {local_id} prop {pname}: {exc}") + key = local_id or node.get_name() + local_map[key] = node + result["spawned"][key] = {"name": node.get_name(), "class": _short(cls), + "properties": applied, + "in_pins": [_pin_label(p) for p in _pins(node, "in")], + "out_pins": [_pin_label(p) for p in _pins(node, "out")]} + except Exception as exc: # noqa: BLE001 + result["errors"].append(f"node {local_id or spec.get('type') or spec.get('class')}: {exc}") + + def resolve(ref): + if isinstance(ref, str) and ref in local_map: + return local_map[ref] + return _resolve_node(graph, ref) + + for e in raw_edges: + try: + f, t = e["from"], e["to"] + fn_node, tn_node = resolve(f[0]), resolve(t[0]) + fp = _pin_or_default(fn_node, "out", f[1] if len(f) > 1 else None) + tp = _pin_or_default(tn_node, "in", t[1] if len(t) > 1 else None) + graph.add_edge(fn_node, unreal.Name(fp), tn_node, unreal.Name(tp)) + result["edges"].append({"from": [fn_node.get_name(), fp], "to": [tn_node.get_name(), tp]}) + except Exception as exc: # noqa: BLE001 + result["errors"].append(f"edge {e}: {exc}") + + for o in args.get("outputs") or []: + try: + src = o.get("from") or o + src_node = resolve(src[0]) + sp = _pin_or_default(src_node, "out", src[1] if len(src) > 1 else None) + out_node = _io_node(graph, "output") + tp = _pin_or_default(out_node, "in", o.get("pin")) + graph.add_edge(src_node, unreal.Name(sp), out_node, unreal.Name(tp)) + result["edges"].append({"from": [src_node.get_name(), sp], "to": ["output", tp]}) + except Exception as exc: # noqa: BLE001 + result["errors"].append(f"output {o}: {exc}") + + if args.get("save", True): + _save(graph) + result["ok"] = not result["errors"] + result["node_count"] = len(_graph_nodes(graph)) + return result + + +def _add_node_inner(graph, cls): + """Spawn one node; returns (node, settings). Shared by add_node/apply_graph.""" + fn = getattr(graph, "add_node_of_type", None) + if callable(fn): + res = fn(cls) + if isinstance(res, (tuple, list)): + return res[0], (res[1] if len(res) > 1 else None) + return res, None + settings = unreal.new_object(cls, graph) + adder = getattr(graph, "add_node", None) or getattr(graph, "add_node_copy", None) + if not callable(adder): + raise RuntimeError("UPCGGraph exposes no add_node/add_node_of_type") + return adder(settings), settings + + +# --------------------------------------------------------------------------- # +# layout +# --------------------------------------------------------------------------- # +def auto_layout(args: dict) -> dict: + """Re-flow existing nodes into edge-depth columns (left-to-right by data flow).""" + if graph_layout is None: + return {"ok": False, "error": "graph_layout helper unavailable"} + graph = _load_graph(args["asset_path"]) + dump = read_graph({"asset_path": args["asset_path"]}) + lay_nodes = [{"name": n["name"], "class": n["class"]} for n in dump.get("nodes", [])] + lay_edges = [{"from": [e.get("from_node")], "to": [e.get("to_node")]} + for e in dump.get("edges", []) if not e.get("partial")] + moves = graph_layout.tidy_existing_nodes(lay_nodes, lay_edges, domain="pcg") + applied = 0 + for m in moves: + try: + node = _resolve_node(graph, m["guid"]) + _apply_position(node, {"x": m["x"], "y": m["y"]}) + applied += 1 + except Exception: + continue + _save(graph) + return {"ok": True, "moved": applied, "count": len(moves)} + + +# --------------------------------------------------------------------------- # +# graph parameters (read-only: UE does not expose FInstancedPropertyBag editing) +# --------------------------------------------------------------------------- # +def list_params(args: dict) -> dict: + """Dump the graph's UserParameters property bag. Note: UE5.7 does NOT expose + property-bag *editing* to Python, so params must be authored in-editor; this + op reports what already exists (name/type/default) so graphs can be wired.""" + graph = _load_graph(args["asset_path"]) + try: + bag = graph.get_editor_property("user_parameters") + except Exception as exc: # noqa: BLE001 + return {"ok": False, "error": f"no user_parameters: {exc}"} + info = {"ok": True, "asset": graph.get_path_name(), "params": []} + try: + info["raw"] = str(bag.export_text()) + except Exception: + pass + return info + + +# --------------------------------------------------------------------------- # +# subgraph wiring +# --------------------------------------------------------------------------- # +def set_subgraph(args: dict) -> dict: + """Point a Subgraph node at a target PCG graph asset. + {asset_path, node, subgraph: '/Game/.../OtherGraph'}""" + graph = _load_graph(args["asset_path"]) + node = _resolve_node(graph, args.get("node")) + settings = _node_settings(node) + if settings is None: + raise RuntimeError(f"node {node.get_name()!r} has no settings") + target = unreal.EditorAssetLibrary.load_asset(str(args["subgraph"])) + if target is None: + raise RuntimeError(f"subgraph asset not found: {args.get('subgraph')!r}") + last = None + for prop in ("subgraph_instance", "subgraph", "subgraph_override"): + try: + settings.set_editor_property(prop, target) + _save(graph) + return {"ok": True, "node": node.get_name(), "property": prop, + "subgraph": target.get_path_name()} + except Exception as exc: # noqa: BLE001 + last = exc + raise RuntimeError(f"could not assign subgraph (tried subgraph_instance/subgraph/subgraph_override): {last}") + + +# --------------------------------------------------------------------------- # +# level / runtime integration (PCG component + volume) +# --------------------------------------------------------------------------- # +def _ess(): + return unreal.get_editor_subsystem(unreal.EditorActorSubsystem) + + +def _find_actor(name: str): + for a in _ess().get_all_level_actors(): + if not a: + continue + try: + if a.get_actor_label() == name or a.get_name() == name or a.get_path_name() == name: + return a + except Exception: + continue + return None + + +def _pcg_component(actor): + """Return the actor's UPCGComponent (PCGVolume.pcg_component, or a component + search for arbitrary actors).""" + try: + c = actor.get_editor_property("pcg_component") + if c is not None: + return c + except Exception: + pass + try: + return actor.get_component_by_class(unreal.PCGComponent) + except Exception: + return None + + +def _graph_output_stats(comp): + """Best-effort point/output count after generation.""" + try: + out = comp.get_generated_graph_output() + items = list(out) if out is not None else [] + return {"output_data": len(items)} + except Exception: + return {} + + +def spawn_volume(args: dict) -> dict: + """Spawn a PCG Volume actor, assign a graph, optionally generate. + {graph?, location?, rotation?, scale?, name?, generate?(default True)}""" + cls = getattr(unreal, "PCGVolume", None) or unreal.load_class(None, "/Script/PCG.PCGVolume") + if cls is None: + return {"ok": False, "error": "PCGVolume class unavailable"} + loc = _vec(args.get("location")) + rot = _rot(args.get("rotation")) + actor = _ess().spawn_actor_from_class(cls, loc, rot) + if actor is None: + return {"ok": False, "error": "spawn returned None"} + scale = args.get("scale") + if scale is not None: + actor.set_actor_scale3d(_vec(scale, (1, 1, 1))) + if args.get("name"): + actor.set_actor_label(str(args["name"])) + comp = _pcg_component(actor) + res = {"ok": True, "actor": actor.get_actor_label(), "path": actor.get_path_name(), + "has_component": comp is not None} + if comp is not None and args.get("graph"): + g = _load_graph(args["graph"]) + comp.set_graph(g) + res["graph"] = g.get_path_name() + if args.get("generate", True): + comp.generate(True) + res["generated"] = True + res.update(_graph_output_stats(comp)) + return res + + +def set_actor_graph(args: dict) -> dict: + """Assign a PCG graph to an existing actor's component. {actor, graph, generate?}""" + actor = _find_actor(str(args["actor"])) + if actor is None: + return {"ok": False, "error": f"actor not found: {args.get('actor')!r}"} + comp = _pcg_component(actor) + if comp is None: + return {"ok": False, "error": "actor has no PCGComponent"} + g = _load_graph(args["graph"]) + comp.set_graph(g) + res = {"ok": True, "actor": actor.get_actor_label(), "graph": g.get_path_name()} + if args.get("generate", False): + comp.generate(True) + res["generated"] = True + res.update(_graph_output_stats(comp)) + return res + + +def generate(args: dict) -> dict: + """Generate (or regenerate) a PCG actor's component. {actor, force?(True)}""" + actor = _find_actor(str(args["actor"])) + if actor is None: + return {"ok": False, "error": f"actor not found: {args.get('actor')!r}"} + comp = _pcg_component(actor) + if comp is None: + return {"ok": False, "error": "actor has no PCGComponent"} + comp.generate(bool(args.get("force", True))) + res = {"ok": True, "actor": actor.get_actor_label(), "generated": True} + res.update(_graph_output_stats(comp)) + return res + + +def cleanup(args: dict) -> dict: + """Clear generated content on a PCG actor's component. {actor}""" + actor = _find_actor(str(args["actor"])) + if actor is None: + return {"ok": False, "error": f"actor not found: {args.get('actor')!r}"} + comp = _pcg_component(actor) + if comp is None: + return {"ok": False, "error": "actor has no PCGComponent"} + remove = bool(args.get("remove_components", True)) + try: + comp.cleanup(remove) # UE5.7: cleanup(remove_components) + except TypeError: + comp.cleanup() # older signature fallback + return {"ok": True, "actor": actor.get_actor_label(), "cleaned": True} + + +def list_components(args: dict) -> dict: + """Enumerate level actors that carry a PCGComponent + their graph/state.""" + found = [] + for a in _ess().get_all_level_actors(): + comp = _pcg_component(a) if a else None + if comp is None: + continue + entry = {"actor": a.get_actor_label(), "path": a.get_path_name(), + "class": a.get_class().get_name()} + try: + gi = comp.get_editor_property("graph_instance") + g = gi.get_mutable_pcg_graph() if gi is not None else None + entry["graph"] = g.get_path_name() if g is not None else None + except Exception: + entry["graph"] = None + try: + entry["generated"] = bool(comp.get_editor_property("generated")) + except Exception: + pass + found.append(entry) + return {"ok": True, "count": len(found), "components": found} + + +# --------------------------------------------------------------------------- # +# scaffolding presets — build a working graph end-to-end in one call +# --------------------------------------------------------------------------- # +_TEMPLATES = { + "surface_scatter": { + "desc": "Input(surface) -> Surface Sampler -> Transform Points -> Static Mesh Spawner -> Output", + "nodes": [ + {"id": "sampler", "type": "PCGSurfaceSamplerSettings", "x": 0, "y": 0}, + {"id": "transform", "type": "PCGTransformPointsSettings", "x": 1, "y": 0}, + {"id": "spawner", "type": "PCGStaticMeshSpawnerSettings", "x": 2, "y": 0}, + ], + "edges": [ + {"from": ["input"], "to": ["sampler"]}, + {"from": ["sampler"], "to": ["transform"]}, + {"from": ["transform"], "to": ["spawner"]}, + ], + "outputs": [{"from": ["spawner"]}], + }, + "points_grid_spawn": { + "desc": "Create Points Grid -> Static Mesh Spawner -> Output", + "nodes": [ + {"id": "grid", "type": "PCGCreatePointsGridSettings", "x": 0, "y": 0}, + {"id": "spawner", "type": "PCGStaticMeshSpawnerSettings", "x": 1, "y": 0}, + ], + "edges": [{"from": ["grid"], "to": ["spawner"]}], + "outputs": [{"from": ["spawner"]}], + }, + "density_filter_scatter": { + "desc": "Input -> Surface Sampler -> Density Filter -> Static Mesh Spawner -> Output", + "nodes": [ + {"id": "sampler", "type": "PCGSurfaceSamplerSettings", "x": 0, "y": 0}, + {"id": "filter", "type": "PCGDensityFilterSettings", "x": 1, "y": 0}, + {"id": "spawner", "type": "PCGStaticMeshSpawnerSettings", "x": 2, "y": 0}, + ], + "edges": [ + {"from": ["input"], "to": ["sampler"]}, + {"from": ["sampler"], "to": ["filter"]}, + {"from": ["filter"], "to": ["spawner"]}, + ], + "outputs": [{"from": ["spawner"]}], + }, +} + + +def scaffold(args: dict) -> dict: + """Create (or populate) a PCG graph from a named template, fully wired. + {path|asset_path, template, create?(True), save?(True)}. + template='__list__' lists available templates.""" + template = str(args.get("template") or "").strip() + if template in ("", "__list__", "list"): + return {"ok": True, "templates": {k: v["desc"] for k, v in _TEMPLATES.items()}} + tpl = _TEMPLATES.get(template) + if tpl is None: + return {"ok": False, "error": f"unknown template {template!r}", + "available": list(_TEMPLATES)} + path = args.get("path") or args.get("asset_path") + if not path: + return {"ok": False, "error": "path (or asset_path) required"} + asset_path = path + if args.get("create", True) and not unreal.EditorAssetLibrary.does_asset_exist(path): + created = create_graph({"path": path}) + asset_path = created.get("asset", path) + res = apply_graph({"asset_path": asset_path, "clear_existing": args.get("clear_existing", False), + "nodes": tpl["nodes"], "edges": tpl["edges"], "outputs": tpl["outputs"], + "auto_layout": True, "save": args.get("save", True)}) + res["template"] = template + res["asset"] = asset_path + return res + + +def _vec(v, default=(0, 0, 0)): + if v is None: + v = default + if isinstance(v, str): + try: + v = json.loads(v) + except Exception: + 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 + if isinstance(v, str): + try: + v = json.loads(v) + except Exception: + v = default + return unreal.Rotator(float(v[0]), float(v[1]), float(v[2])) + + +OPS = { + "api": api, + "create_graph": create_graph, + "list_node_types": list_node_types, + "read_graph": read_graph, + "add_node": add_node, + "delete_node": delete_node, + "connect": connect, + "disconnect": disconnect, + "list_node_properties": list_node_properties, + "set_node_property": set_node_property, + "move_nodes": move_nodes, + "save": save, + "open_asset": open_asset, + # declarative authoring + layout + "apply_graph": apply_graph, + "auto_layout": auto_layout, + "tidy_graph": auto_layout, + "layout": auto_layout, + # parameters + subgraph + "list_params": list_params, + "set_subgraph": set_subgraph, + # level / runtime + "spawn_volume": spawn_volume, + "set_actor_graph": set_actor_graph, + "generate": generate, + "cleanup": cleanup, + "list_components": list_components, + # presets + "scaffold": scaffold, +} + + +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-PCG] " + text[:1500]) + except Exception: + pass + return text diff --git a/ue_side/presets.py b/ue_side/presets.py new file mode 100644 index 0000000..f7c17ec --- /dev/null +++ b/ue_side/presets.py @@ -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 diff --git a/ue_side/render.py b/ue_side/render.py new file mode 100644 index 0000000..c9b68a6 --- /dev/null +++ b/ue_side/render.py @@ -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 /Saved/Screenshots//"} + 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 diff --git a/ue_side/selection.py b/ue_side/selection.py new file mode 100644 index 0000000..fa3daca --- /dev/null +++ b/ue_side/selection.py @@ -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 diff --git a/ue_side/validation.py b/ue_side/validation.py new file mode 100644 index 0000000..2f808fb --- /dev/null +++ b/ue_side/validation.py @@ -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 diff --git a/ue_side/variables.py b/ue_side/variables.py new file mode 100644 index 0000000..65dc7c6 --- /dev/null +++ b/ue_side/variables.py @@ -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 diff --git a/ue_side/voxel_graph.py b/ue_side/voxel_graph.py new file mode 100644 index 0000000..df66f09 --- /dev/null +++ b/ue_side/voxel_graph.py @@ -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 diff --git a/ue_side/widget_tree.py b/ue_side/widget_tree.py new file mode 100644 index 0000000..9ebbdd1 --- /dev/null +++ b/ue_side/widget_tree.py @@ -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/ + 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