From 4b9bd297ffd1753480ddb185ddb154a364f8ea89 Mon Sep 17 00:00:00 2001 From: CyberExploiter <89822085+CyberExploiter@users.noreply.github.com> Date: Sat, 11 Apr 2026 19:04:35 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20Add=20MCP=20Bridge=20=E2=80=94=20connec?= =?UTF-8?q?t=20Claude/MCP=20clients=20to=20CUE4Parse=20for=20asset=20extra?= =?UTF-8?q?ction=20##=20What=20this=20adds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new **MCP Bridge** window (under the Views menu) that lets Claude Desktop (or any MCP client) connect directly to CUE4Parse for real-time asset extraction — no manual export needed. ## New files - `FModel/Views/McpBridge.xaml` / `McpBridge.xaml.cs` — Bridge UI window - `CUE4Parse-MCP/` — MCP server process (named pipe transport, stdio bridge) - `CUE4Parse-MCP-Proxy/` — Persistent proxy Claude Desktop spawns via `claude_desktop_config.json` ## Modified files - `FModel/MainWindow.xaml` — Added "MCP Bridge" entry to the Views menu - `FModel/ViewModels/Commands/MenuCommand.cs` — Added `Views_McpBridge` handler - `FModel/ViewModels/CUE4ParseViewModel.cs` — Supporting changes ## How it works 1. User opens **Views → MCP Bridge** and clicks **Start Bridge** 2. FModel auto-detects the `.usmap` mappings from its own `.data` cache and passes them to the bridge 3. Claude Desktop connects via the proxy and can call tools: `load_game`, `list_assets`, `search_assets`, `extract_json`, `get_skeleton`, `get_bounds` ## Dependencies added (CUE4Parse-MCP projects only) - `Newtonsoft.Json` - `Serilog` / `Serilog.Sinks.File` ## Build See `CUE4Parse-MCP/BUILD.md` for setup instructions. --- .../CUE4Parse-MCP-Proxy.csproj | 12 + CUE4Parse-MCP-Proxy/Program.cs | 224 +++++++++ CUE4Parse-MCP/BUILD.md | 61 +++ CUE4Parse-MCP/CUE4Parse-MCP.csproj | 19 + CUE4Parse-MCP/GameProvider.cs | 419 +++++++++++++++++ CUE4Parse-MCP/Program.cs | 399 ++++++++++++++++ FModel/MainWindow.xaml | 10 + FModel/ViewModels/CUE4ParseViewModel.cs | 5 +- FModel/ViewModels/Commands/MenuCommand.cs | 3 + FModel/Views/McpBridge.xaml | 107 +++++ FModel/Views/McpBridge.xaml.cs | 425 ++++++++++++++++++ 11 files changed, 1682 insertions(+), 2 deletions(-) create mode 100644 CUE4Parse-MCP-Proxy/CUE4Parse-MCP-Proxy.csproj create mode 100644 CUE4Parse-MCP-Proxy/Program.cs create mode 100644 CUE4Parse-MCP/BUILD.md create mode 100644 CUE4Parse-MCP/CUE4Parse-MCP.csproj create mode 100644 CUE4Parse-MCP/GameProvider.cs create mode 100644 CUE4Parse-MCP/Program.cs create mode 100644 FModel/Views/McpBridge.xaml create mode 100644 FModel/Views/McpBridge.xaml.cs diff --git a/CUE4Parse-MCP-Proxy/CUE4Parse-MCP-Proxy.csproj b/CUE4Parse-MCP-Proxy/CUE4Parse-MCP-Proxy.csproj new file mode 100644 index 00000000..b9a2619f --- /dev/null +++ b/CUE4Parse-MCP-Proxy/CUE4Parse-MCP-Proxy.csproj @@ -0,0 +1,12 @@ + + + Exe + net8.0 + CUE4Parse-MCP-Proxy + enable + enable + + + + + diff --git a/CUE4Parse-MCP-Proxy/Program.cs b/CUE4Parse-MCP-Proxy/Program.cs new file mode 100644 index 00000000..63adf6d9 --- /dev/null +++ b/CUE4Parse-MCP-Proxy/Program.cs @@ -0,0 +1,224 @@ +// CUE4Parse-MCP-Proxy (persistent MCP server — Claude Desktop spawns this once and it stays alive) +// +// Architecture: +// - Claude Desktop spawns this proxy at startup via claude_desktop_config.json "command" +// - Proxy handles initialize / tools/list itself so tools always appear in Claude +// - For actual tool calls (tools/call), proxy opens a named pipe to the bridge, +// does a quick initialize handshake, sends the request, returns the response, closes pipe +// - If bridge isn't running, tool calls return a helpful error — no restart needed +// - When FModel starts the bridge and you say "ping bridge", the proxy connects instantly +// +// Named pipe: \\.\pipe\cue4parse-mcp (same as CUE4Parse-MCP.exe) + +using System.IO.Pipes; +using System.Text; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +const string PipeName = "cue4parse-mcp"; +const int BridgeTimeout = 5000; // ms to wait for bridge connection per tool call + +// ── Tool definitions — declared up front so the main loop can reference them ── +var ToolList = JArray.Parse(""" +[ + { + "name": "ping_bridge", + "description": "Test connectivity to the FModel CUE4Parse bridge. Returns pong + status if bridge is running, or an error with instructions if it is not.", + "inputSchema": { "type": "object", "properties": {} } + }, + { + "name": "load_game", + "description": "Load a game's IoStore/PAK archives. Must be called before any other tool. Game state persists across calls.", + "inputSchema": { + "type": "object", + "properties": { + "game_dir": { "type": "string", "description": "Path to game install directory" }, + "game": { "type": "string", "description": "Game identifier e.g. GAME_ARKSurvivalAscended", "default": "GAME_ARKSurvivalAscended" }, + "aes_key": { "type": "string", "description": "Optional AES-256 key (0x... hex)" } + }, + "required": ["game_dir"] + } + }, + { + "name": "list_assets", + "description": "List assets under a virtual path.", + "inputSchema": { + "type": "object", + "properties": { + "path": { "type": "string" }, + "extension_filter": { "type": "string" }, + "limit": { "type": "integer", "default": 100 } + }, + "required": ["path"] + } + }, + { + "name": "extract_json", + "description": "Extract a single asset as JSON.", + "inputSchema": { + "type": "object", + "properties": { + "asset_path": { "type": "string" }, + "export_index": { "type": "integer", "default": -1 } + }, + "required": ["asset_path"] + } + }, + { + "name": "get_skeleton", + "description": "Extract bone hierarchy from a USkeleton or USkeletalMesh.", + "inputSchema": { + "type": "object", + "properties": { "asset_path": { "type": "string" } }, + "required": ["asset_path"] + } + }, + { + "name": "get_bounds", + "description": "Extract ImportedBounds from a USkeletalMesh.", + "inputSchema": { + "type": "object", + "properties": { "asset_path": { "type": "string" } }, + "required": ["asset_path"] + } + }, + { + "name": "search_assets", + "description": "Search asset paths by substring.", + "inputSchema": { + "type": "object", + "properties": { + "pattern": { "type": "string" }, + "extension_filter": { "type": "string" }, + "limit": { "type": "integer", "default": 50 } + }, + "required": ["pattern"] + } + } +] +"""); + +var stdin = new StreamReader(Console.OpenStandardInput(), Encoding.UTF8); +var stdout = new StreamWriter(Console.OpenStandardOutput(), new UTF8Encoding(false)) +{ + AutoFlush = true, + NewLine = "\n" +}; + +// ── MCP message loop ────────────────────────────────────────────────────────── +while (true) +{ + string? line; + try { line = await stdin.ReadLineAsync(); } + catch { break; } + + if (line == null) break; + if (string.IsNullOrWhiteSpace(line)) continue; + + JObject req; + try { req = JObject.Parse(line); } + catch { continue; } + + var method = req["method"]?.ToString(); + var id = req["id"]; + + // Notifications have no id — no response + if (id == null) continue; + + JObject resp = method switch + { + "initialize" => Result(id, new JObject + { + ["protocolVersion"] = "2024-11-05", + ["capabilities"] = new JObject { ["tools"] = new JObject { ["listChanged"] = false } }, + ["serverInfo"] = new JObject { ["name"] = "cue4parse-mcp-proxy", ["version"] = "2.0.0" } + }), + + "tools/list" => Result(id, new JObject { ["tools"] = ToolList }), + + "tools/call" => await ForwardToolCallAsync(id, req), + + "ping" => Result(id, new JObject()), + "shutdown" => Result(id, new JObject()), + + _ => Error(id, -32601, $"Method not found: {method}") + }; + + await stdout.WriteLineAsync(resp.ToString(Formatting.None)); + + if (method == "shutdown") break; +} + +// ── Bridge forwarding ───────────────────────────────────────────────────────── +// Each tool call opens a fresh pipe connection, does the MCP handshake, sends +// the request, reads the response, then closes. The bridge keeps game state in +// a shared GameProvider across connections so load_game only needs one call. + +async Task ForwardToolCallAsync(JToken? id, JObject originalReq) +{ + try + { + using var pipe = new NamedPipeClientStream(".", PipeName, PipeDirection.InOut, PipeOptions.Asynchronous); + + try { await pipe.ConnectAsync(BridgeTimeout); } + catch { return BridgeDownError(id); } + + var pr = new StreamReader(pipe, Encoding.UTF8); + var pw = new StreamWriter(pipe, new UTF8Encoding(false)) { AutoFlush = true, NewLine = "\n" }; + + // ── MCP initialize handshake with bridge ── + var initReq = new JObject + { + ["jsonrpc"] = "2.0", ["id"] = 0, ["method"] = "initialize", + ["params"] = new JObject + { + ["protocolVersion"] = "2024-11-05", + ["capabilities"] = new JObject(), + ["clientInfo"] = new JObject { ["name"] = "proxy", ["version"] = "2.0" } + } + }; + await pw.WriteLineAsync(initReq.ToString(Formatting.None)); + + using var initCts = new CancellationTokenSource(BridgeTimeout); + var initLine = await pr.ReadLineAsync(initCts.Token); + if (initLine == null) return BridgeDownError(id); + + // Send initialized notification (bridge expects it before accepting tool calls) + await pw.WriteLineAsync("{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}"); + + // ── Forward the actual request ── + await pw.WriteLineAsync(originalReq.ToString(Formatting.None)); + + using var respCts = new CancellationTokenSource(30_000); // 30 s for tool execution + var respLine = await pr.ReadLineAsync(respCts.Token); + if (respLine == null) return Error(id, -32000, "Bridge returned no response"); + + var resp = JObject.Parse(respLine); + resp["id"] = id; // fix up id to match what Claude sent + return resp; + } + catch (Exception ex) + { + return Error(id, -32000, $"Proxy error: {ex.Message}"); + } +} + +JObject BridgeDownError(JToken? id) => Result(id, new JObject +{ + ["content"] = new JArray + { + new JObject + { + ["type"] = "text", + ["text"] = "Bridge not running — open FModel and click Views → MCP Bridge → Start Bridge" + } + }, + ["isError"] = true +}); + +JObject Result(JToken? id, JObject result) => + new() { ["jsonrpc"] = "2.0", ["id"] = id, ["result"] = result }; + +JObject Error(JToken? id, int code, string message) => + new() { ["jsonrpc"] = "2.0", ["id"] = id, ["error"] = new JObject { ["code"] = code, ["message"] = message } }; + diff --git a/CUE4Parse-MCP/BUILD.md b/CUE4Parse-MCP/BUILD.md new file mode 100644 index 00000000..2432a8d4 --- /dev/null +++ b/CUE4Parse-MCP/BUILD.md @@ -0,0 +1,61 @@ +# CUE4Parse MCP Server - Build & Setup + +## Build + +Open a terminal in the `CUE4Parse-MCP` directory and run: + +``` +dotnet build -c Release +``` + +The built executable will be at: +``` +bin\Release\net8.0\CUE4Parse-MCP.exe +``` + +## Register as MCP Server + +Add this to your Claude desktop config at `%APPDATA%\Claude\claude_desktop_config.json`: + +```json +{ + "mcpServers": { + "cue4parse": { + "command": "\\CUE4Parse-MCP\\bin\\Release\\net8.0\\CUE4Parse-MCP.exe", + "args": [] + } + } +} +``` + +Then restart Claude desktop. + +## Available Tools + +- **load_game** - Load ARK SA archives (call this first) +- **list_assets** - Browse virtual file tree +- **search_assets** - Search assets by name pattern +- **extract_json** - Full JSON export of any asset +- **get_skeleton** - Extract bone hierarchy from skeleton/mesh assets +- **get_bounds** - Extract mesh bounding box data + +## Example Usage (from Claude) + +1. Load the game: + ``` + load_game(game_dir: "C:/path/to/ARK Survival Ascended", game: "GAME_ARKSurvivalAscended") + ``` + +2. Search for a Rex skeleton: + ``` + search_assets(pattern: "Rex_Skeleton", extension_filter: "uasset") + ``` + +3. Extract the skeleton: + ``` + get_skeleton(asset_path: "ShooterGame/Content/ASA/Dinos/Rex/Rex_Skeleton.uasset") + ``` + +## Logs + +Logs are written to `cue4parse-mcp.log` in the exe's directory. Check here for errors. diff --git a/CUE4Parse-MCP/CUE4Parse-MCP.csproj b/CUE4Parse-MCP/CUE4Parse-MCP.csproj new file mode 100644 index 00000000..77cdc4e6 --- /dev/null +++ b/CUE4Parse-MCP/CUE4Parse-MCP.csproj @@ -0,0 +1,19 @@ + + + Exe + net8.0 + enable + true + enable + + + + + + + + + + + + diff --git a/CUE4Parse-MCP/GameProvider.cs b/CUE4Parse-MCP/GameProvider.cs new file mode 100644 index 00000000..6e31fcb0 --- /dev/null +++ b/CUE4Parse-MCP/GameProvider.cs @@ -0,0 +1,419 @@ +using CUE4Parse.Compression; +using CUE4Parse.Encryption.Aes; +using CUE4Parse.FileProvider; +using CUE4Parse.FileProvider.Vfs; +using CUE4Parse.MappingsProvider; +using CUE4Parse.UE4.Assets; +using CUE4Parse.UE4.Assets.Exports; +using CUE4Parse.UE4.Assets.Exports.Animation; +using CUE4Parse.UE4.Assets.Exports.SkeletalMesh; +using CUE4Parse.UE4.Objects.Core.Math; +using CUE4Parse.UE4.Objects.Core.Misc; +using CUE4Parse.UE4.Versions; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Serilog; + +namespace CUE4Parse_MCP; + +public class GameProvider +{ + // volatile ensures the reference is visible across threads without a full lock + private volatile DefaultFileProvider? _provider; + + // Set by Program.cs from the --mappings CLI arg passed by McpBridge at launch + private readonly string? _defaultMappingsPath; + + public GameProvider(string? defaultMappingsPath = null) + { + _defaultMappingsPath = defaultMappingsPath; + } + + public string LoadGame(JObject args) + { + var gameDir = args["game_dir"]?.ToString() + ?? throw new ArgumentException("game_dir is required"); + var gameName = args["game"]?.ToString() ?? "GAME_ARKSurvivalAscended"; + var aesKey = args["aes_key"]?.ToString(); + // Explicit arg overrides default; default was passed by McpBridge from FModel's .data cache + var mappingsPath = args["mappings_path"]?.ToString() ?? _defaultMappingsPath; + + if (!Directory.Exists(gameDir)) + throw new DirectoryNotFoundException($"Game directory not found: {gameDir}"); + + // Parse game enum + if (!Enum.TryParse(gameName, true, out var game)) + throw new ArgumentException($"Unknown game: {gameName}. Use values from CUE4Parse.UE4.Versions.EGame"); + + Log.Information("Loading game from {Dir} as {Game}", gameDir, game); + + // Initialize Oodle decompression (required for IoStore .ucas files) + if (OodleHelper.Instance == null) + { + // Check common game DLL locations first (avoids a network download) + var oodleCandidates = new[] + { + Path.Combine(gameDir, "ShooterGame", "Binaries", "Win64", "oo2core_9_win64.dll"), + Path.Combine(gameDir, "Engine", "Binaries", "Win64", "oo2core_9_win64.dll"), + Path.Combine(gameDir, "Engine", "Binaries", "ThirdParty", "Oodle", "Win64", "UnrealOodle.dll"), + Path.Combine(AppContext.BaseDirectory, OodleHelper.OODLE_NAME_OLD), + Path.Combine(AppContext.BaseDirectory, OodleHelper.OODLE_NAME_CURRENT), + }; + + var oodlePath = oodleCandidates.FirstOrDefault(File.Exists); + if (oodlePath != null) + { + Log.Information("Using Oodle DLL: {Path}", oodlePath); + BridgeLog.Emit("SERVER", $"Oodle: {oodlePath}"); + OodleHelper.Initialize(oodlePath); + } + else + { + // Fall back to auto-download from GitHub (OodleUE shim) + Log.Information("Oodle DLL not found locally — downloading shim"); + BridgeLog.Emit("SERVER", "Oodle DLL not found locally, downloading shim..."); + OodleHelper.Initialize(); // downloads oodle-data-shared.dll if needed + BridgeLog.Emit("SERVER", "Oodle initialized via download"); + } + } + + _provider = new DefaultFileProvider(gameDir, SearchOption.AllDirectories, true, + new VersionContainer(game)); + + _provider.Initialize(); + Log.Information("Initialized: {Count} files indexed", _provider.Files.Count); + + // Load type mappings (.usmap) — required for games with unversioned properties (UE5, ARK SA, etc.) + // Auto-detect from: explicit arg > bridge exe dir > common FModel output dirs + var usmapCandidates = new List(); + if (!string.IsNullOrEmpty(mappingsPath)) usmapCandidates.Add(mappingsPath); + usmapCandidates.AddRange(new[] + { + // Next to the bridge exe (user can drop .usmap here) + Path.Combine(AppContext.BaseDirectory, "Mappings.usmap"), + Path.Combine(AppContext.BaseDirectory, "mappings.usmap"), + // FModel release output + Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", + "FModel", "bin", "Release", "net8.0-windows", "win-x64", "Output", "Mappings", "ArkSurvivalAscended.usmap"), + Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", + "FModel", "bin", "Release", "net8.0-windows", "win-x64", "Output", "Mappings", "Mappings.usmap"), + // FModel debug output + Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", + "FModel", "bin", "Debug", "net8.0-windows", "win-x64", "Output", "Mappings", "ArkSurvivalAscended.usmap"), + }); + + var resolvedUsmap = usmapCandidates.Select(p => Path.GetFullPath(p)).FirstOrDefault(File.Exists); + if (resolvedUsmap != null) + { + _provider.MappingsContainer = new FileUsmapTypeMappingsProvider(resolvedUsmap); + Log.Information("Mappings loaded: {Path}", resolvedUsmap); + BridgeLog.Emit("SERVER", $"Mappings: {resolvedUsmap}"); + } + else + { + BridgeLog.Emit("SERVER", + "WARNING: No .usmap mappings file found. Assets with unversioned properties will fail to deserialize. " + + "Export mappings from FModel (Settings → Advanced → Save Mappings) and place the .usmap next to CUE4Parse-MCP.exe, " + + "or pass mappings_path to load_game."); + } + + // Submit AES key if provided + if (!string.IsNullOrEmpty(aesKey)) + { + var key = new FAesKey(aesKey); + _provider.SubmitKey(new FGuid(), key); + Log.Information("AES key submitted"); + } + else + { + // Try with empty key (unencrypted) + _provider.SubmitKey(new FGuid(), new FAesKey("0x0000000000000000000000000000000000000000000000000000000000000000")); + } + + // Mount and count + var totalFiles = _provider.Files.Count; + var mountedVfs = _provider.MountedVfs.Count; + + return JsonConvert.SerializeObject(new + { + success = true, + total_files = totalFiles, + mounted_archives = mountedVfs, + game = game.ToString() + }, Formatting.Indented); + } + + public string ListAssets(JObject args) + { + EnsureLoaded(); + + var pathPrefix = args["path"]?.ToString() ?? ""; + var extFilter = args["extension_filter"]?.ToString(); + var limit = args["limit"]?.Value() ?? 100; + + var results = _provider!.Files + .Where(kv => kv.Key.Contains(pathPrefix, StringComparison.OrdinalIgnoreCase)) + .Where(kv => string.IsNullOrEmpty(extFilter) || + kv.Key.EndsWith($".{extFilter}", StringComparison.OrdinalIgnoreCase)) + .Take(limit) + .Select(kv => new { path = kv.Key, size = kv.Value.Size }) + .ToList(); + + return JsonConvert.SerializeObject(new + { + count = results.Count, + truncated = results.Count >= limit, + assets = results + }, Formatting.Indented); + } + + public string ExtractJson(JObject args) + { + EnsureLoaded(); + + var assetPath = args["asset_path"]?.ToString() + ?? throw new ArgumentException("asset_path is required"); + + var pathNoExt = StripExtension(assetPath); + Log.Information("Extracting: {Path}", pathNoExt); + + var package = _provider!.LoadPackage(pathNoExt); + var exports = package.GetExports().ToList(); + + Log.Information("Loaded {Count} exports from {Path}", exports.Count, pathNoExt); + + // Serialize to JSON + var settings = new JsonSerializerSettings + { + Formatting = Formatting.Indented, + ReferenceLoopHandling = ReferenceLoopHandling.Ignore, + Error = (sender, errorArgs) => + { + Log.Warning("JSON serialization error: {Error}", errorArgs.ErrorContext.Error.Message); + errorArgs.ErrorContext.Handled = true; + } + }; + + var result = new JArray(); + foreach (var export in exports) + { + try + { + var json = JsonConvert.SerializeObject(export, settings); + result.Add(new JObject + { + ["type"] = export.GetType().Name, + ["name"] = export.Name, + ["data"] = JToken.Parse(json) + }); + } + catch (Exception ex) + { + result.Add(new JObject + { + ["type"] = export.GetType().Name, + ["name"] = export.Name, + ["error"] = $"{ex.GetType().Name}: {ex.Message}" + }); + } + } + + return result.ToString(Formatting.Indented); + } + + public string GetSkeleton(JObject args) + { + EnsureLoaded(); + + var assetPath = args["asset_path"]?.ToString() + ?? throw new ArgumentException("asset_path is required"); + + var pathNoExt = StripExtension(assetPath); + Log.Information("Loading skeleton from: {Path}", pathNoExt); + + var package = _provider!.LoadPackage(pathNoExt); + var exports = package.GetExports().ToList(); + + // Look for USkeleton first, then USkeletalMesh + var skeleton = exports.OfType().FirstOrDefault(); + if (skeleton != null) + { + return ExtractSkeletonData(skeleton.ReferenceSkeleton, skeleton.Name); + } + + var skelMesh = exports.OfType().FirstOrDefault(); + if (skelMesh != null) + { + return ExtractSkeletonData(skelMesh.ReferenceSkeleton, skelMesh.Name, + skelMesh.ImportedBounds); + } + + // Fallback: try to serialize whatever we find + var types = exports.Select(e => e.GetType().Name).ToList(); + return JsonConvert.SerializeObject(new + { + error = "No USkeleton or USkeletalMesh found in package", + found_types = types, + export_count = exports.Count + }, Formatting.Indented); + } + + public string GetBounds(JObject args) + { + EnsureLoaded(); + + var assetPath = args["asset_path"]?.ToString() + ?? throw new ArgumentException("asset_path is required"); + + var pathNoExt = StripExtension(assetPath); + Log.Information("Loading bounds from: {Path}", pathNoExt); + + var package = _provider!.LoadPackage(pathNoExt); + var exports = package.GetExports().ToList(); + + var skelMesh = exports.OfType().FirstOrDefault(); + if (skelMesh == null) + { + var types = exports.Select(e => e.GetType().Name).ToList(); + return JsonConvert.SerializeObject(new + { + error = "No USkeletalMesh found", + found_types = types + }, Formatting.Indented); + } + + var bounds = skelMesh.ImportedBounds; + return JsonConvert.SerializeObject(new + { + name = skelMesh.Name, + imported_bounds = new + { + origin = new { x = bounds.Origin.X, y = bounds.Origin.Y, z = bounds.Origin.Z }, + box_extent = new { x = bounds.BoxExtent.X, y = bounds.BoxExtent.Y, z = bounds.BoxExtent.Z }, + sphere_radius = bounds.SphereRadius + } + }, Formatting.Indented); + } + + public string SearchAssets(JObject args) + { + EnsureLoaded(); + + var pattern = args["pattern"]?.ToString() + ?? throw new ArgumentException("pattern is required"); + var extFilter = args["extension_filter"]?.ToString(); + var limit = args["limit"]?.Value() ?? 50; + + var results = _provider!.Files + .Where(kv => kv.Key.Contains(pattern, StringComparison.OrdinalIgnoreCase)) + .Where(kv => string.IsNullOrEmpty(extFilter) || + kv.Key.EndsWith($".{extFilter}", StringComparison.OrdinalIgnoreCase)) + .Take(limit) + .Select(kv => kv.Key) + .ToList(); + + return JsonConvert.SerializeObject(new + { + pattern, + count = results.Count, + truncated = results.Count >= limit, + paths = results + }, Formatting.Indented); + } + + // ---- Helpers ---- + + private void EnsureLoaded() + { + if (_provider == null) + throw new InvalidOperationException("No game loaded. Call load_game first."); + } + + private static string StripExtension(string path) + { + foreach (var ext in new[] { ".uasset", ".umap", ".uexp" }) + { + if (path.EndsWith(ext, StringComparison.OrdinalIgnoreCase)) + return path[..^ext.Length]; + } + return path; + } + + private static string ExtractSkeletonData( + FReferenceSkeleton refSkel, + string name, + FBoxSphereBounds? bounds = null) + { + var bones = new JArray(); + + if (refSkel?.FinalRefBoneInfo != null) + { + for (int i = 0; i < refSkel.FinalRefBoneInfo.Length; i++) + { + var bone = refSkel.FinalRefBoneInfo[i]; + var boneObj = new JObject + { + ["index"] = i, + ["name"] = bone.Name.Text, + ["parent_index"] = bone.ParentIndex + }; + + // Add ref pose transform if available + if (refSkel.FinalRefBonePose != null && i < refSkel.FinalRefBonePose.Length) + { + var pose = refSkel.FinalRefBonePose[i]; + boneObj["ref_position"] = new JObject + { + ["x"] = pose.Translation.X, + ["y"] = pose.Translation.Y, + ["z"] = pose.Translation.Z + }; + boneObj["ref_rotation"] = new JObject + { + ["x"] = pose.Rotation.X, + ["y"] = pose.Rotation.Y, + ["z"] = pose.Rotation.Z, + ["w"] = pose.Rotation.W + }; + boneObj["ref_scale"] = new JObject + { + ["x"] = pose.Scale3D.X, + ["y"] = pose.Scale3D.Y, + ["z"] = pose.Scale3D.Z + }; + } + + bones.Add(boneObj); + } + } + + var result = new JObject + { + ["name"] = name, + ["bone_count"] = bones.Count, + ["bones"] = bones + }; + + if (bounds != null) + { + result["imported_bounds"] = new JObject + { + ["origin"] = new JObject + { + ["x"] = bounds.Origin.X, + ["y"] = bounds.Origin.Y, + ["z"] = bounds.Origin.Z + }, + ["box_extent"] = new JObject + { + ["x"] = bounds.BoxExtent.X, + ["y"] = bounds.BoxExtent.Y, + ["z"] = bounds.BoxExtent.Z + }, + ["sphere_radius"] = bounds.SphereRadius + }; + } + + return result.ToString(Formatting.Indented); + } +} diff --git a/CUE4Parse-MCP/Program.cs b/CUE4Parse-MCP/Program.cs new file mode 100644 index 00000000..e693f47d --- /dev/null +++ b/CUE4Parse-MCP/Program.cs @@ -0,0 +1,399 @@ +using System.Diagnostics; +using System.IO.Pipes; +using System.Text; +using CUE4Parse_MCP; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Serilog; + +Log.Logger = new LoggerConfiguration() + .MinimumLevel.Debug() + .WriteTo.File( + Path.Combine(AppContext.BaseDirectory, "cue4parse-mcp.log"), + rollingInterval: RollingInterval.Day, + outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}") + .CreateLogger(); + +Log.Information("CUE4Parse-MCP server starting (named pipe transport)"); +BridgeLog.Emit("SERVER", "CUE4Parse-MCP v2.0.0 starting up"); +BridgeLog.Emit("SERVER", $"Named pipe: \\\\.\\pipe\\{PipeTransport.PipeName}"); +BridgeLog.Emit("SERVER", "FModel controls this process — waiting for proxy connections"); + +// Parse --mappings from CLI args (passed by McpBridge when starting this process) +string? defaultMappingsPath = null; +{ + var cliArgs = Environment.GetCommandLineArgs(); + for (int i = 1; i < cliArgs.Length - 1; i++) + { + if (cliArgs[i].Equals("--mappings", StringComparison.OrdinalIgnoreCase)) + { + defaultMappingsPath = cliArgs[i + 1]; + break; + } + } + if (defaultMappingsPath != null && File.Exists(defaultMappingsPath)) + BridgeLog.Emit("SERVER", $"Default mappings: {defaultMappingsPath}"); + else if (defaultMappingsPath != null) + { + BridgeLog.Emit("SERVER", $"WARNING: --mappings path not found: {defaultMappingsPath}"); + defaultMappingsPath = null; + } + else + BridgeLog.Emit("SERVER", "No --mappings arg — will search candidates at load_game time"); +} + +var transport = new PipeTransport(defaultMappingsPath); + +using var cts = new CancellationTokenSource(); +Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); }; + +try +{ + await transport.RunAsync(cts.Token); +} +catch (OperationCanceledException) { } +catch (Exception ex) +{ + Log.Fatal(ex, "MCP server crashed"); + BridgeLog.Emit("FATAL", $"Server crashed: {ex.Message}"); +} +finally +{ + BridgeLog.Emit("SERVER", "Shutting down"); + Log.CloseAndFlush(); +} + +namespace CUE4Parse_MCP +{ + public static class BridgeLog + { + private static readonly object Lock = new(); + private static readonly TextWriter StdErr = Console.Error; + + public static readonly string EventLogPath = + Path.Combine(AppContext.BaseDirectory, "bridge-events.log"); + + private static readonly StreamWriter EventLogWriter = OpenEventLog(); + + private static StreamWriter OpenEventLog() + { + try + { + var fs = new FileStream(EventLogPath, + FileMode.Create, FileAccess.Write, FileShare.ReadWrite); + return new StreamWriter(fs, new UTF8Encoding(false)) { AutoFlush = true }; + } + catch { return new StreamWriter(Stream.Null); } + } + + public static void Emit(string tag, string message) + { + var ts = DateTime.Now.ToString("HH:mm:ss.fff"); + var line = $"[{ts}] {tag,-8} | {message}"; + lock (Lock) + { + StdErr.WriteLine(line); + StdErr.Flush(); + EventLogWriter.WriteLine(line); + } + } + + public static void Request(string method, JToken? id, string? summary = null) + { + var idStr = id?.ToString() ?? "n/a"; + var msg = summary != null ? $"{method} (id={idStr}) {summary}" : $"{method} (id={idStr})"; + Emit("REQ", msg); + } + + public static void Response(JToken? id, int bytes, double elapsedMs) + => Emit("RESP", $"id={id} | {bytes} bytes | {elapsedMs:F1}ms"); + + public static void ToolCall(string toolName, JObject? args) + { + var argsSummary = args != null ? TruncateJson(args.ToString(Formatting.None), 200) : "{}"; + Emit("TOOL", $"→ {toolName}({argsSummary})"); + } + + public static void ToolResult(string toolName, bool isError, int resultBytes, double elapsedMs) + => Emit("TOOL", $"← {toolName} [{(isError ? "ERROR" : "OK")}] {resultBytes} bytes in {elapsedMs:F1}ms"); + + public static void Error(string message) => Emit("ERROR", message); + + private static string TruncateJson(string json, int max) + => json.Length <= max ? json : json[..max] + "…"; + } + + /// + /// MCP transport over a Windows named pipe. + /// FModel starts this bridge; Claude Desktop connects via CUE4Parse-MCP-Proxy.exe. + /// Each proxy connection gets its own pipe instance and ToolHandler session. + /// GameProvider (asset data) is shared across all sessions. + /// + public class PipeTransport + { + public const string PipeName = "cue4parse-mcp"; + + // Shared provider so game assets only need to be loaded once + private readonly GameProvider _sharedProvider; + + public PipeTransport(string? defaultMappingsPath = null) + { + _sharedProvider = new GameProvider(defaultMappingsPath); + } + + public async Task RunAsync(CancellationToken ct) + { + int sessionCount = 0; // accessed via Interlocked below + + while (!ct.IsCancellationRequested) + { + var pipe = new NamedPipeServerStream( + PipeName, + PipeDirection.InOut, + NamedPipeServerStream.MaxAllowedServerInstances, + PipeTransmissionMode.Byte, + PipeOptions.Asynchronous); + + try + { + BridgeLog.Emit("SERVER", "Waiting for proxy connection…"); + await pipe.WaitForConnectionAsync(ct); + + var sessionId = Interlocked.Increment(ref sessionCount); + BridgeLog.Emit("SERVER", $"Proxy connected (session #{sessionId})"); + + // Each session gets its own handler that shares the provider + var sessionHandler = new ToolHandler(_sharedProvider); + _ = Task.Run(() => HandleSessionAsync(pipe, sessionHandler, sessionId, ct), ct); + } + catch (OperationCanceledException) { pipe.Dispose(); break; } + catch (Exception ex) + { + BridgeLog.Error($"Pipe accept error: {ex.Message}"); + pipe.Dispose(); + } + } + } + + private static async Task HandleSessionAsync( + NamedPipeServerStream pipe, + ToolHandler handler, + int sessionId, + CancellationToken ct) + { + try + { + var reader = new StreamReader(pipe, Encoding.UTF8); + var writer = new StreamWriter(pipe, new UTF8Encoding(false)) + { + AutoFlush = true, + NewLine = "\n" + }; + + while (!ct.IsCancellationRequested) + { + string? line; + try { line = await reader.ReadLineAsync(ct); } + catch { break; } + + if (line == null) break; + if (string.IsNullOrWhiteSpace(line)) continue; + + JObject request; + try { request = JObject.Parse(line); } + catch (Exception ex) + { + BridgeLog.Error($"Malformed JSON: {ex.Message}"); + continue; + } + + var method = request["method"]?.ToString() ?? "?"; + var id = request["id"]; + + string? summary = method == "tools/call" + ? $"tool={request["params"]?["name"]}" + : null; + + BridgeLog.Request(method, id, summary); + + var sw = Stopwatch.StartNew(); + var response = handler.HandleRequest(request); + sw.Stop(); + + if (response != null) + { + var responseStr = response.ToString(Formatting.None); + BridgeLog.Response(id, Encoding.UTF8.GetByteCount(responseStr), sw.Elapsed.TotalMilliseconds); + await writer.WriteLineAsync(responseStr); + } + } + } + catch { /* client disconnected */ } + finally + { + BridgeLog.Emit("SERVER", $"Session #{sessionId} disconnected"); + pipe.Dispose(); + } + } + } + + public class ToolHandler + { + private readonly GameProvider _provider; + + // Constructor for shared provider (used by PipeTransport sessions) + public ToolHandler(GameProvider provider) => _provider = provider; + + // Default constructor (standalone use) + public ToolHandler() => _provider = new GameProvider(); + + private static readonly JArray ToolDefinitions = JArray.Parse(""" + [ + { + "name": "load_game", + "description": "Load a game's IoStore/PAK archives. Must be called before any other tool.", + "inputSchema": { + "type": "object", + "properties": { + "game_dir": { "type": "string", "description": "Path to game install directory" }, + "game": { "type": "string", "description": "Game identifier e.g. GAME_ARKSurvivalAscended", "default": "GAME_ARKSurvivalAscended" }, + "aes_key": { "type": "string", "description": "Optional AES-256 key (0x... hex)" } + }, + "required": ["game_dir"] + } + }, + { + "name": "list_assets", + "description": "List assets under a virtual path.", + "inputSchema": { + "type": "object", + "properties": { + "path": { "type": "string", "description": "Virtual path prefix" }, + "extension_filter": { "type": "string" }, + "limit": { "type": "integer", "default": 100 } + }, + "required": ["path"] + } + }, + { + "name": "extract_json", + "description": "Extract a single asset as JSON.", + "inputSchema": { + "type": "object", + "properties": { + "asset_path": { "type": "string" }, + "export_index": { "type": "integer", "default": -1 } + }, + "required": ["asset_path"] + } + }, + { + "name": "get_skeleton", + "description": "Extract bone hierarchy from a USkeleton or USkeletalMesh.", + "inputSchema": { + "type": "object", + "properties": { "asset_path": { "type": "string" } }, + "required": ["asset_path"] + } + }, + { + "name": "get_bounds", + "description": "Extract ImportedBounds from a USkeletalMesh.", + "inputSchema": { + "type": "object", + "properties": { "asset_path": { "type": "string" } }, + "required": ["asset_path"] + } + }, + { + "name": "search_assets", + "description": "Search asset paths by substring.", + "inputSchema": { + "type": "object", + "properties": { + "pattern": { "type": "string" }, + "extension_filter": { "type": "string" }, + "limit": { "type": "integer", "default": 50 } + }, + "required": ["pattern"] + } + } + ] + """); + + public JObject? HandleRequest(JObject request) + { + var method = request["method"]?.ToString(); + var id = request["id"]; + + bool isNotification = id == null && method != null && + (method.StartsWith("notifications/") || method == "initialized"); + if (isNotification) return null; + + return method switch + { + "initialize" => MakeResponse(id, new JObject + { + ["protocolVersion"] = "2024-11-05", + ["capabilities"] = new JObject { ["tools"] = new JObject { ["listChanged"] = false } }, + ["serverInfo"] = new JObject { ["name"] = "cue4parse-mcp", ["version"] = "2.0.0" } + }), + "tools/list" => MakeResponse(id, new JObject { ["tools"] = ToolDefinitions }), + "tools/call" => HandleToolsCall(id, request["params"] as JObject), + "ping" => MakeResponse(id, new JObject()), + "shutdown" => MakeResponse(id, new JObject()), + _ => MakeErrorResponse(id, -32601, $"Method not found: {method}") + }; + } + + private JObject HandleToolsCall(JToken? id, JObject? parameters) + { + var toolName = parameters?["name"]?.ToString() ?? "unknown"; + var args = parameters?["arguments"] as JObject ?? new JObject(); + + BridgeLog.ToolCall(toolName, args); + var sw = Stopwatch.StartNew(); + + try + { + var result = toolName switch + { + "ping_bridge" => $"{{\"status\":\"pong\",\"bridge\":\"CUE4Parse-MCP v2.0.0\",\"time\":\"{DateTime.Now:HH:mm:ss}\"}}", + "load_game" => _provider.LoadGame(args), + "list_assets" => _provider.ListAssets(args), + "extract_json" => _provider.ExtractJson(args), + "get_skeleton" => _provider.GetSkeleton(args), + "get_bounds" => _provider.GetBounds(args), + "search_assets" => _provider.SearchAssets(args), + _ => throw new Exception($"Unknown tool: {toolName}") + }; + + sw.Stop(); + BridgeLog.ToolResult(toolName, false, Encoding.UTF8.GetByteCount(result), sw.Elapsed.TotalMilliseconds); + return MakeResponse(id, new JObject + { + ["content"] = new JArray { new JObject { ["type"] = "text", ["text"] = result } } + }); + } + catch (Exception ex) + { + sw.Stop(); + Log.Error(ex, "Tool '{Tool}' failed", toolName); + var msg = $"{ex.GetType().Name}: {ex.Message}"; + BridgeLog.ToolResult(toolName, true, Encoding.UTF8.GetByteCount(msg), sw.Elapsed.TotalMilliseconds); + BridgeLog.Error($"{toolName}: {msg}"); + return MakeResponse(id, new JObject + { + ["content"] = new JArray { new JObject { ["type"] = "text", ["text"] = $"ERROR: {msg}\n{ex.StackTrace}" } }, + ["isError"] = true + }); + } + } + + private static JObject MakeResponse(JToken? id, JObject result) + => new() { ["jsonrpc"] = "2.0", ["id"] = id, ["result"] = result }; + + private static JObject MakeErrorResponse(JToken? id, int code, string message) + => new() { ["jsonrpc"] = "2.0", ["id"] = id, ["error"] = new JObject { ["code"] = code, ["message"] = message } }; + } +} diff --git a/FModel/MainWindow.xaml b/FModel/MainWindow.xaml index 46d2ddb5..42655f5e 100644 --- a/FModel/MainWindow.xaml +++ b/FModel/MainWindow.xaml @@ -152,6 +152,16 @@ + + + + + + + + + + diff --git a/FModel/ViewModels/CUE4ParseViewModel.cs b/FModel/ViewModels/CUE4ParseViewModel.cs index df1d3560..3144ebb9 100644 --- a/FModel/ViewModels/CUE4ParseViewModel.cs +++ b/FModel/ViewModels/CUE4ParseViewModel.cs @@ -583,9 +583,10 @@ public class CUE4ParseViewModel : ViewModel { action(entry.Asset); } - catch + catch (Exception ex) { - // ignore + Interlocked.Increment(ref FailedExportCount); + Log.Error(ex, "BulkFolder failed to extract '{AssetPath}'", entry.Asset.Path); } } diff --git a/FModel/ViewModels/Commands/MenuCommand.cs b/FModel/ViewModels/Commands/MenuCommand.cs index fe223f88..9aa0c3fe 100644 --- a/FModel/ViewModels/Commands/MenuCommand.cs +++ b/FModel/ViewModels/Commands/MenuCommand.cs @@ -46,6 +46,9 @@ public class MenuCommand : ViewModelCommand case "Views_ImageMerger": Helper.OpenWindow("Image Merger", () => new ImageMerger().Show()); break; + case "Views_McpBridge": + Helper.OpenWindow("MCP Bridge", () => new McpBridge().Show()); + break; case "Settings": Helper.OpenWindow("Settings", () => new SettingsView().Show()); break; diff --git a/FModel/Views/McpBridge.xaml b/FModel/Views/McpBridge.xaml new file mode 100644 index 00000000..57ea4d8c --- /dev/null +++ b/FModel/Views/McpBridge.xaml @@ -0,0 +1,107 @@ + + + + + + + + + + + + + + + + + + + + + + + +