mirror of
https://github.com/4sval/FModel.git
synced 2026-05-10 06:03:12 -05:00
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:
parent
72ca2c50c5
commit
4b9bd297ff
12
CUE4Parse-MCP-Proxy/CUE4Parse-MCP-Proxy.csproj
Normal file
12
CUE4Parse-MCP-Proxy/CUE4Parse-MCP-Proxy.csproj
Normal 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>
|
||||
224
CUE4Parse-MCP-Proxy/Program.cs
Normal file
224
CUE4Parse-MCP-Proxy/Program.cs
Normal 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
61
CUE4Parse-MCP/BUILD.md
Normal 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.
|
||||
19
CUE4Parse-MCP/CUE4Parse-MCP.csproj
Normal file
19
CUE4Parse-MCP/CUE4Parse-MCP.csproj
Normal 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>
|
||||
419
CUE4Parse-MCP/GameProvider.cs
Normal file
419
CUE4Parse-MCP/GameProvider.cs
Normal 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
399
CUE4Parse-MCP/Program.cs
Normal 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 } };
|
||||
}
|
||||
}
|
||||
|
|
@ -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" >
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
107
FModel/Views/McpBridge.xaml
Normal 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>
|
||||
425
FModel/Views/McpBridge.xaml.cs
Normal file
425
FModel/Views/McpBridge.xaml.cs
Normal 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; } = "";
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user