mirror of
https://github.com/4sval/FModel.git
synced 2026-05-10 14:34:28 -05:00
## 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.
225 lines
8.3 KiB
C#
225 lines
8.3 KiB
C#
// 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 } };
|
|
|