feat: Add MCP Bridge — connect Claude/MCP clients to CUE4Parse for asset extraction

## What this adds

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.
This commit is contained in:
CyberExploiter 2026-04-11 19:04:35 -05:00
parent 72ca2c50c5
commit 4b9bd297ff
11 changed files with 1682 additions and 2 deletions

View File

@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<AssemblyName>CUE4Parse-MCP-Proxy</AssemblyName>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup>
</Project>

View File

@ -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<JObject> 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 } };

61
CUE4Parse-MCP/BUILD.md Normal file
View File

@ -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": "<path-to-repo>\\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.

View File

@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\CUE4Parse\CUE4Parse\CUE4Parse.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
<PackageReference Include="Serilog" Version="4.3.0" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
</ItemGroup>
</Project>

View File

@ -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<EGame>(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<string>();
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<int>() ?? 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<USkeleton>().FirstOrDefault();
if (skeleton != null)
{
return ExtractSkeletonData(skeleton.ReferenceSkeleton, skeleton.Name);
}
var skelMesh = exports.OfType<USkeletalMesh>().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<USkeletalMesh>().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<int>() ?? 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);
}
}

399
CUE4Parse-MCP/Program.cs Normal file
View File

@ -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 <path> 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] + "…";
}
/// <summary>
/// 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.
/// </summary>
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 } };
}
}

View File

@ -152,6 +152,16 @@
</Viewbox>
</MenuItem.Icon>
</MenuItem>
<Separator />
<MenuItem Header="MCP Bridge" Command="{Binding MenuCommand}" CommandParameter="Views_McpBridge">
<MenuItem.Icon>
<Viewbox Width="16" Height="16">
<Canvas Width="24" Height="24">
<Path Fill="{DynamicResource {x:Static adonisUi:Brushes.AccentForegroundBrush}}" Data="M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M12,4A8,8 0 0,1 20,12A8,8 0 0,1 12,20A8,8 0 0,1 4,12A8,8 0 0,1 12,4M9,8V16H11V13H13V16H15V8H13V11H11V8H9Z" />
</Canvas>
</Viewbox>
</MenuItem.Icon>
</MenuItem>
</MenuItem>
<MenuItem Header="Settings" Command="{Binding MenuCommand}" CommandParameter="Settings" />
<MenuItem Header="Help" >

View File

@ -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);
}
}

View File

@ -46,6 +46,9 @@ public class MenuCommand : ViewModelCommand<ApplicationViewModel>
case "Views_ImageMerger":
Helper.OpenWindow<AdonisWindow>("Image Merger", () => new ImageMerger().Show());
break;
case "Views_McpBridge":
Helper.OpenWindow<AdonisWindow>("MCP Bridge", () => new McpBridge().Show());
break;
case "Settings":
Helper.OpenWindow<AdonisWindow>("Settings", () => new SettingsView().Show());
break;

107
FModel/Views/McpBridge.xaml Normal file
View File

@ -0,0 +1,107 @@
<adonisControls:AdonisWindow x:Class="FModel.Views.McpBridge"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:adonisControls="clr-namespace:AdonisUI.Controls;assembly=AdonisUI"
xmlns:adonisUi="clr-namespace:AdonisUI;assembly=AdonisUI"
WindowStartupLocation="CenterScreen" ResizeMode="CanResizeWithGrip"
IconVisibility="Collapsed" Width="600" MinWidth="480" MinHeight="500" SizeToContent="Height">
<adonisControls:AdonisWindow.Style>
<Style TargetType="adonisControls:AdonisWindow" BasedOn="{StaticResource {x:Type adonisControls:AdonisWindow}}">
<Setter Property="Title" Value="MCP Bridge" />
</Style>
</adonisControls:AdonisWindow.Style>
<StackPanel Margin="20 15">
<!-- Header -->
<TextBlock Text="CUE4Parse MCP Bridge" FontSize="18" FontWeight="600"
Foreground="#9DA3DD" Margin="0 0 0 5" />
<TextBlock Text="Connects Claude (or any MCP client) directly to CUE4Parse for asset extraction."
TextWrapping="Wrap" Foreground="#888" Margin="0 0 0 15" />
<!-- Status -->
<Border Background="{DynamicResource {x:Static adonisUi:Brushes.Layer1BackgroundBrush}}"
CornerRadius="4" Padding="12 8" Margin="0 0 0 12">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Ellipse x:Name="StatusDot" Grid.Column="0" Width="10" Height="10"
Fill="#666" Margin="0 0 10 0" VerticalAlignment="Center" />
<TextBlock x:Name="StatusText" Grid.Column="1" Text="Stopped"
VerticalAlignment="Center" FontSize="13" />
</Grid>
</Border>
<!-- Start / Stop Button -->
<Button x:Name="ToggleButton" Content="Start Bridge" Height="36" FontSize="14"
Click="OnToggleClick" Margin="0 0 0 15"
Style="{DynamicResource {x:Static adonisUi:Styles.AccentButton}}" />
<!-- Info Section -->
<Border Background="{DynamicResource {x:Static adonisUi:Brushes.Layer1BackgroundBrush}}"
CornerRadius="4" Padding="12 10" Margin="0 0 0 12">
<StackPanel>
<TextBlock Text="Setup Instructions" FontWeight="600" Margin="0 0 0 8" />
<TextBlock TextWrapping="Wrap" Margin="0 0 0 6" Foreground="#AAA">
<Run Text="1. " FontWeight="600" />
<Run Text="Click " />
<Run Text="Start Bridge" FontWeight="600" Foreground="#9DA3DD" />
<Run Text=" above. The MCP server will launch in the background." />
</TextBlock>
<TextBlock TextWrapping="Wrap" Margin="0 0 0 6" Foreground="#AAA">
<Run Text="2. " FontWeight="600" />
<Run Text="In your MCP client config, register the server:" />
</TextBlock>
<Border Background="#1A1A2E" CornerRadius="3" Padding="10 8" Margin="10 0 0 6">
<TextBlock x:Name="ConfigSnippet" FontFamily="Consolas" FontSize="11"
Foreground="#CCC" TextWrapping="Wrap" />
</Border>
<Button x:Name="CopyConfigButton" Content="Copy Config" Height="28" FontSize="11"
Click="OnCopyConfigClick" HorizontalAlignment="Left" Margin="10 0 0 6"
Padding="12 0" />
<TextBlock TextWrapping="Wrap" Margin="0 4 0 0" Foreground="#AAA">
<Run Text="3. " FontWeight="600" />
<Run Text="Restart your MCP client. The " />
<Run Text="cue4parse" FontWeight="600" Foreground="#9DA3DD" />
<Run Text=" tools will be available (load_game, get_skeleton, etc.)." />
</TextBlock>
</StackPanel>
</Border>
<!-- Log output -->
<Grid Margin="0 0 0 0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="Protocol Log" FontWeight="600" VerticalAlignment="Center" />
<StackPanel Grid.Column="1" Orientation="Horizontal">
<CheckBox x:Name="FilterReq" Content="REQ" IsChecked="True" Margin="0 0 6 0"
Foreground="#6BA3F7" FontSize="11" Checked="OnFilterChanged" Unchecked="OnFilterChanged" />
<CheckBox x:Name="FilterResp" Content="RESP" IsChecked="True" Margin="0 0 6 0"
Foreground="#4EC94E" FontSize="11" Checked="OnFilterChanged" Unchecked="OnFilterChanged" />
<CheckBox x:Name="FilterTool" Content="TOOL" IsChecked="True" Margin="0 0 6 0"
Foreground="#C89BF7" FontSize="11" Checked="OnFilterChanged" Unchecked="OnFilterChanged" />
<CheckBox x:Name="FilterError" Content="ERR" IsChecked="True" Margin="0 0 6 0"
Foreground="#F76B6B" FontSize="11" Checked="OnFilterChanged" Unchecked="OnFilterChanged" />
<CheckBox x:Name="FilterServer" Content="SRV" IsChecked="True" Margin="0 0 10 0"
Foreground="#888" FontSize="11" Checked="OnFilterChanged" Unchecked="OnFilterChanged" />
<Button Content="Clear" Height="22" FontSize="10" Padding="8 0"
Click="OnClearLogClick" VerticalAlignment="Center" />
</StackPanel>
</Grid>
<Border Background="#0D0D1A" CornerRadius="3" Padding="8" Height="220" Margin="0 4 0 0">
<ScrollViewer x:Name="LogScrollViewer" VerticalScrollBarVisibility="Auto">
<RichTextBox x:Name="LogOutput" Background="Transparent" BorderThickness="0"
FontFamily="Consolas" FontSize="11" IsReadOnly="True"
Foreground="#888" VerticalScrollBarVisibility="Disabled" />
</ScrollViewer>
</Border>
</StackPanel>
</adonisControls:AdonisWindow>

View File

@ -0,0 +1,425 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading;
using System.Windows;
using System.Windows.Documents;
using System.Windows.Media;
using AdonisUI.Controls;
using FModel.Settings;
namespace FModel.Views;
public partial class McpBridge : AdonisWindow
{
private Process? _bridgeProcess;
private bool _isRunning;
// File-tail state — we read bridge-events.log written by CUE4Parse-MCP
// This works regardless of whether FModel or Claude Desktop spawned the bridge
private Timer? _tailTimer;
private long _tailPosition;
private string _eventLogPath = "";
// All log entries kept in memory for re-filtering
private readonly List<LogEntry> _logEntries = new();
private const int MaxLogEntries = 2000;
// Resolve repo root: FModel exe lives at <repo>/FModel/bin/Release/net8.0-windows/win-x64/
private static readonly string RepoRoot = Path.GetFullPath(Path.Combine(
AppContext.BaseDirectory, "..", "..", "..", "..", ".."));
private static readonly string BridgeExePath = Path.Combine(
RepoRoot, "CUE4Parse-MCP", "bin", "Release", "net8.0", "CUE4Parse-MCP.exe");
private static readonly string BridgeProjectDir = Path.Combine(
RepoRoot, "CUE4Parse-MCP");
// Tag → color mapping (IDA-style)
private static readonly Dictionary<string, Color> TagColors = new()
{
["REQ"] = Color.FromRgb(0x6B, 0xA3, 0xF7), // blue
["RESP"] = Color.FromRgb(0x4E, 0xC9, 0x4E), // green
["TOOL"] = Color.FromRgb(0xC8, 0x9B, 0xF7), // purple
["ERROR"] = Color.FromRgb(0xF7, 0x6B, 0x6B), // red
["FATAL"] = Color.FromRgb(0xF7, 0x6B, 0x6B), // red
["SERVER"] = Color.FromRgb(0x88, 0x88, 0x88), // gray
};
public McpBridge()
{
InitializeComponent();
try { UpdateStatus(false); } catch { /* ignore during init */ }
try { UpdateConfigSnippet(); } catch { /* ignore during init */ }
// Tail bridge-events.log — catches all traffic from the bridge regardless of who started it
StartFileTailing();
// Kill the bridge when FModel exits (not just when this window closes)
Application.Current.Exit += (_, _) =>
{
StopBridge();
StopFileTailing();
};
}
// ---- Bridge Process Management ----
private void OnToggleClick(object sender, RoutedEventArgs e)
{
if (_isRunning)
StopBridge();
else
StartBridge();
}
private void StartBridge()
{
var exePath = Path.GetFullPath(BridgeExePath);
if (!File.Exists(exePath))
{
AppendLog("SERVER", "Bridge exe not found. Build CUE4Parse-MCP first.");
return;
}
try
{
AppendLog("SERVER", $"Starting: {exePath}");
// Auto-detect the .usmap mappings file from FModel's own .data cache
// FModel downloads/caches usmap files as *_oo.usmap in {OutputDirectory}\.data\
string? mappingsPath = null;
var dataDir = Path.Combine(UserSettings.Default.OutputDirectory, ".data");
if (Directory.Exists(dataDir))
{
var latestUsmap = new DirectoryInfo(dataDir)
.GetFiles("*_oo.usmap")
.OrderByDescending(f => f.LastWriteTime)
.FirstOrDefault();
if (latestUsmap != null)
mappingsPath = latestUsmap.FullName;
}
if (mappingsPath != null)
AppendLog("SERVER", $"Auto-detected mappings: {mappingsPath}");
else
AppendLog("SERVER", "WARNING: No *_oo.usmap found in .data dir — unversioned properties won't deserialize");
_bridgeProcess = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = exePath,
Arguments = mappingsPath != null ? $"--mappings \"{mappingsPath}\"" : "",
UseShellExecute = false,
// Stdin/stdout are needed by MCP; we do NOT redirect stderr here
// because the bridge writes structured logs to bridge-events.log
// and that file is already being tailed by StartFileTailing().
RedirectStandardInput = false,
RedirectStandardOutput = false,
RedirectStandardError = false,
CreateNoWindow = true
},
EnableRaisingEvents = true
};
_bridgeProcess.Exited += (_, _) =>
{
Dispatcher.Invoke(() =>
{
AppendLog("SERVER", "Bridge process exited.");
UpdateStatus(false);
});
};
_bridgeProcess.Start();
UpdateStatus(true);
AppendLog("SERVER", $"Bridge started (PID {_bridgeProcess.Id}) — watching bridge-events.log");
}
catch (Exception ex)
{
AppendLog("ERROR", $"Failed to start: {ex.Message}");
UpdateStatus(false);
}
}
// ---- File Tailing ----
private void StartFileTailing()
{
_eventLogPath = Path.Combine(
Path.GetDirectoryName(Path.GetFullPath(BridgeExePath)) ?? "",
"bridge-events.log");
// Reset tail position — if the file already exists show from the start,
// but a new bridge run will truncate the file so _tailPosition > fileLen
// resets automatically in TailLogFile().
_tailPosition = 0;
// Poll every 150 ms — snappy enough for live logs, low enough CPU
_tailTimer = new Timer(_ => TailLogFile(), null, 0, 150);
}
private void TailLogFile()
{
try
{
if (!File.Exists(_eventLogPath)) return;
using var fs = new FileStream(_eventLogPath,
FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
// Bridge restarted and truncated the file
if (_tailPosition > fs.Length) _tailPosition = 0;
if (_tailPosition >= fs.Length) return;
fs.Seek(_tailPosition, SeekOrigin.Begin);
using var reader = new StreamReader(fs, Encoding.UTF8, leaveOpen: true);
string? line;
while ((line = reader.ReadLine()) != null)
{
var captured = line;
Dispatcher.BeginInvoke(() => ParseAndAppendBridgeLine(captured));
}
_tailPosition = fs.Position;
}
catch { /* ignore transient file-access errors */ }
}
private void StopFileTailing()
{
_tailTimer?.Dispose();
_tailTimer = null;
}
private void StopBridge()
{
var proc = _bridgeProcess;
_bridgeProcess = null;
UpdateStatus(false);
if (proc == null) return;
// Kill on a background thread so the UI thread never blocks
System.Threading.Tasks.Task.Run(() =>
{
try
{
if (!proc.HasExited) proc.Kill(true);
proc.Dispose();
}
catch { }
}).ContinueWith(_ =>
Dispatcher.BeginInvoke(() => AppendLog("SERVER", "Bridge stopped.")));
}
// ---- Structured Log Parsing ----
/// <summary>
/// Parses a structured log line from the bridge process stderr.
/// Expected format: [HH:mm:ss.fff] TAG | message
/// Falls back to raw display if the format doesn't match.
/// </summary>
private void ParseAndAppendBridgeLine(string line)
{
// Try to parse the structured format
// [12:34:56.789] REQ | tools/call (id=1) tool=load_game
if (line.Length > 16 && line[0] == '[' && line[13] == ']')
{
var afterTimestamp = line[15..]; // skip "] "
var pipeIdx = afterTimestamp.IndexOf('|');
if (pipeIdx > 0)
{
var tag = afterTimestamp[..pipeIdx].Trim();
var message = afterTimestamp[(pipeIdx + 1)..].TrimStart();
AppendLog(tag, message);
return;
}
}
// Fallback: treat as a plain server message
AppendLog("SERVER", line);
}
// ---- Log Display ----
private void AppendLog(string tag, string message)
{
var entry = new LogEntry
{
Timestamp = DateTime.Now,
Tag = tag,
Message = message
};
_logEntries.Add(entry);
// Trim old entries
while (_logEntries.Count > MaxLogEntries)
_logEntries.RemoveAt(0);
if (IsTagVisible(tag))
{
AddLogParagraph(entry);
ScrollToBottom();
}
}
private void AddLogParagraph(LogEntry entry)
{
var paragraph = new Paragraph { Margin = new Thickness(0, 0, 0, 1) };
// Timestamp in dim gray
var tsRun = new Run($"[{entry.Timestamp:HH:mm:ss.fff}] ")
{
Foreground = new SolidColorBrush(Color.FromRgb(0x55, 0x55, 0x55))
};
paragraph.Inlines.Add(tsRun);
// Tag in its color, bold
var tagColor = TagColors.GetValueOrDefault(entry.Tag, Color.FromRgb(0x88, 0x88, 0x88));
var tagRun = new Run($"{entry.Tag,-8}")
{
Foreground = new SolidColorBrush(tagColor),
FontWeight = FontWeights.Bold
};
paragraph.Inlines.Add(tagRun);
// Separator
paragraph.Inlines.Add(new Run("│ ") { Foreground = new SolidColorBrush(Color.FromRgb(0x44, 0x44, 0x44)) });
// Message — highlight arrows for TOOL lines
if (entry.Tag == "TOOL" && entry.Message.Length > 1)
{
var arrow = entry.Message[0];
if (arrow == '→' || arrow == '←')
{
var arrowColor = arrow == '→'
? Color.FromRgb(0x6B, 0xA3, 0xF7) // blue for outgoing
: Color.FromRgb(0x4E, 0xC9, 0x4E); // green for incoming
paragraph.Inlines.Add(new Run($"{arrow} ") { Foreground = new SolidColorBrush(arrowColor), FontWeight = FontWeights.Bold });
paragraph.Inlines.Add(new Run(entry.Message[2..]) { Foreground = new SolidColorBrush(Color.FromRgb(0xCC, 0xCC, 0xCC)) });
}
else
{
paragraph.Inlines.Add(new Run(entry.Message) { Foreground = new SolidColorBrush(Color.FromRgb(0xCC, 0xCC, 0xCC)) });
}
}
else if (entry.Tag == "ERROR" || entry.Tag == "FATAL")
{
paragraph.Inlines.Add(new Run(entry.Message) { Foreground = new SolidColorBrush(Color.FromRgb(0xF7, 0x6B, 0x6B)) });
}
else
{
paragraph.Inlines.Add(new Run(entry.Message) { Foreground = new SolidColorBrush(Color.FromRgb(0xAA, 0xAA, 0xAA)) });
}
LogOutput.Document.Blocks.Add(paragraph);
}
private void ScrollToBottom()
{
LogScrollViewer.ScrollToEnd();
}
// ---- Filtering ----
private bool IsTagVisible(string tag)
{
return tag switch
{
"REQ" => FilterReq.IsChecked == true,
"RESP" => FilterResp.IsChecked == true,
"TOOL" => FilterTool.IsChecked == true,
"ERROR" or "FATAL" => FilterError.IsChecked == true,
"SERVER" => FilterServer.IsChecked == true,
_ => true
};
}
private void OnFilterChanged(object sender, RoutedEventArgs e)
{
// Guard: CheckBox events fire during InitializeComponent before LogOutput is ready
if (LogOutput == null) return;
RebuildLogDisplay();
}
private void RebuildLogDisplay()
{
LogOutput.Document.Blocks.Clear();
foreach (var entry in _logEntries)
{
if (IsTagVisible(entry.Tag))
AddLogParagraph(entry);
}
ScrollToBottom();
}
private void OnClearLogClick(object sender, RoutedEventArgs e)
{
_logEntries.Clear();
LogOutput.Document.Blocks.Clear();
}
// ---- Status / Config ----
private void UpdateStatus(bool running)
{
_isRunning = running;
StatusDot.Fill = running
? new SolidColorBrush(Color.FromRgb(0x4E, 0xC9, 0x4E))
: new SolidColorBrush(Color.FromRgb(0x66, 0x66, 0x66));
StatusText.Text = running ? "Running" : "Stopped";
ToggleButton.Content = running ? "Stop Bridge" : "Start Bridge";
}
// Proxy exe is what claude_desktop_config.json points to — it relays stdio to the bridge's named pipe
private static readonly string ProxyExePath = Path.Combine(
RepoRoot, "CUE4Parse-MCP-Proxy", "bin", "Release", "net8.0", "CUE4Parse-MCP-Proxy.exe");
private string McpConfig => "{\n \"mcpServers\": {\n \"cue4parse\": {\n \"command\": \""
+ Path.GetFullPath(ProxyExePath).Replace("\\", "\\\\")
+ "\"\n }\n }\n}";
private void UpdateConfigSnippet()
{
ConfigSnippet.Text = McpConfig;
}
private void OnCopyConfigClick(object sender, RoutedEventArgs e)
{
try
{
Clipboard.SetText(McpConfig);
AppendLog("SERVER", "Config copied to clipboard.");
}
catch (Exception ex)
{
AppendLog("ERROR", $"Copy failed: {ex.Message}");
}
}
protected override void OnClosed(EventArgs e)
{
// Stop tailing the log file when the window closes
// Bridge keeps running — FModel.Exit kills it
StopFileTailing();
base.OnClosed(e);
}
// ---- Log Entry Model ----
private class LogEntry
{
public DateTime Timestamp { get; init; }
public string Tag { get; init; } = "";
public string Message { get; init; } = "";
}
}