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 @@
+
+