unreal-mcp-proxy 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "unreal-mcp-proxy",
3
+ "version": "0.1.0",
4
+ "description": "Session recorder, offline flight viewer, and agent skillset for Epic's Unreal MCP (UE 5.8+).",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "HyunJin Yoo",
8
+ "repository": { "type": "git", "url": "git+https://github.com/rapina/unreal-mcp-proxy.git" },
9
+ "bin": { "unreal-mcp-proxy": "dist/cli.js" },
10
+ "main": "dist/index.js",
11
+ "types": "dist/index.d.ts",
12
+ "exports": { ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" } },
13
+ "files": ["dist", "skills", "README.md", "LICENSE"],
14
+ "scripts": {
15
+ "build": "tsc -p tsconfig.json && node viewer/build.mjs",
16
+ "typecheck": "tsc -p tsconfig.json && tsc -p viewer/tsconfig.json && tsc -p tsconfig.test.json",
17
+ "test": "npm run build && node --test \"test/*.test.ts\"",
18
+ "start": "npm run build && node dist/cli.js",
19
+ "prepublishOnly": "npm run typecheck && npm test"
20
+ },
21
+ "engines": { "node": ">=20" },
22
+ "keywords": ["unreal", "unreal-engine", "ue5", "mcp", "model-context-protocol", "proxy", "observability", "session-recording", "claude", "ai-agent"],
23
+ "devDependencies": {
24
+ "@types/node": "^24.0.0",
25
+ "esbuild": "^0.28.1",
26
+ "typescript": "^7.0.0"
27
+ }
28
+ }
@@ -0,0 +1,88 @@
1
+ ---
2
+ name: unreal-mcp-observer
3
+ description: Query and operate the Unreal MCP session recorder (unreal-mcp-proxy). Use when an Unreal MCP tool call fails (check for past similar failures and their fixes first), when a call seems unusually slow, when the user asks what happened in an Unreal editor automation session, or when you need to share a deep link to a specific call, check recorder status, or start a fresh observation session. After diagnosing a failure, record the conclusion as an annotation so future sessions can recall it.
4
+ ---
5
+
6
+ # Unreal MCP Observer
7
+
8
+ Your Unreal MCP traffic is being recorded by [unreal-mcp-proxy](https://github.com/rapina/unreal-mcp-proxy).
9
+ This skill lets you query that history: past failures and how they were resolved, per-tool
10
+ latency baselines, and full request/response bodies of any call.
11
+
12
+ All commands print JSON. Run them with node (no dependencies):
13
+
14
+ ```bash
15
+ node <skill-dir>/scripts/query.mjs <command> [args]
16
+ ```
17
+
18
+ Set `UNREAL_MCP_PROXY_DATA_DIR` if the proxy's data directory is not `./data`
19
+ (it is printed at proxy startup). `UNREAL_MCP_PROXY_URL` defaults to `http://127.0.0.1:35100`.
20
+
21
+ ## Workflow
22
+
23
+ 1. **An Unreal MCP tool call failed?** Before retrying or guessing, check history:
24
+
25
+ ```bash
26
+ node scripts/query.mjs similar-failures "Cannot remove file as it is read only"
27
+ ```
28
+
29
+ Matches include any annotations left by previous sessions - a past agent may have
30
+ already recorded the root cause and the fix (e.g. Unreal + Git LFS file locking
31
+ makes assets read-only until `git lfs lock`).
32
+
33
+ 2. **A call seems slow?** Compare against the recorded baseline:
34
+
35
+ ```bash
36
+ node scripts/query.mjs tool-stats save_assets
37
+ ```
38
+
39
+ 3. **Need the exact request/response of a call?**
40
+
41
+ ```bash
42
+ node scripts/query.mjs call-detail <callId-prefix>
43
+ ```
44
+
45
+ 4. **Diagnosed something non-obvious?** Record it for future sessions (yours and others'):
46
+
47
+ ```bash
48
+ node scripts/query.mjs annotate <callId> --severity error \
49
+ --title "Asset save fails under LFS locking" \
50
+ --summary "save_assets fails because .uasset files are checked out read-only" \
51
+ --suggestion "run git lfs lock <path> before saving, unlock after push"
52
+ ```
53
+
54
+ Annotations appear in the session viewer next to the call, and are returned by
55
+ `similar-failures` forever after. This is the memory loop: record once, recall always.
56
+
57
+ 5. **Reporting a finding to the user?** Hand them a deep link instead of describing the
58
+ call - they will see the exact request/response and your annotation in the viewer:
59
+
60
+ ```bash
61
+ node scripts/query.mjs link <callId>
62
+ ```
63
+
64
+ 6. **Starting a distinct piece of work** (and the user asked for a fresh recording)?
65
+
66
+ ```bash
67
+ node scripts/query.mjs clear
68
+ ```
69
+
70
+ ## Commands
71
+
72
+ | Command | Purpose |
73
+ | --- | --- |
74
+ | `status` | Proxy health, active session + URL, and how many sessions/calls/failures are recorded |
75
+ | `sessions [--limit N]` | Recorded sessions with call/failure counts and viewer URLs, newest first |
76
+ | `link <callId>` | Deep link for one call - share it with a human (callId prefix is enough) |
77
+ | `clear` | Roll over to a new observation session (history is kept; requires the proxy running) |
78
+ | `recent-failures [--limit N]` | Latest failed calls with error text, across all recorded sessions |
79
+ | `similar-failures <text> [--limit N]` | Past failures matching an error message (paths/ids/numbers are masked before matching) + their annotations |
80
+ | `call-detail <callId>` | One call with full request/response bodies (callId prefix is enough) |
81
+ | `tool-stats [tool]` | Per-tool call count, failure rate, p50/p95 duration |
82
+ | `annotate <callId> --title --summary [...]` | Attach a diagnosis to a call (requires the proxy to be running) |
83
+
84
+ Notes:
85
+ - Query results are advisory context. If a query fails (proxy not running, no data dir),
86
+ continue your task without it.
87
+ - Session viewer for humans: open a session at `http://127.0.0.1:35100/sessions/<id>`,
88
+ or open the standalone `viewer.html` and drop a `data/sessions/*.jsonl` file into it.
@@ -0,0 +1,225 @@
1
+ #!/usr/bin/env node
2
+ // Zero-dependency query CLI over recorded unreal-mcp-proxy sessions.
3
+ // Usage: node query.mjs <recent-failures|similar-failures|call-detail|tool-stats|annotate> [args]
4
+ // Env: UNREAL_MCP_PROXY_DATA_DIR (default ./data), UNREAL_MCP_PROXY_URL (default http://127.0.0.1:35100)
5
+
6
+ import { readdir, readFile } from "node:fs/promises";
7
+ import path from "node:path";
8
+
9
+ const dataDir = path.resolve(process.env.UNREAL_MCP_PROXY_DATA_DIR ?? "./data");
10
+ const proxyUrl = (process.env.UNREAL_MCP_PROXY_URL ?? "http://127.0.0.1:35100").replace(/\/$/, "");
11
+ const [command, ...rest] = process.argv.slice(2);
12
+
13
+ const flags = {};
14
+ const positional = [];
15
+ for (let index = 0; index < rest.length; index += 1) {
16
+ const arg = rest[index];
17
+ if (arg.startsWith("--")) { flags[arg.slice(2)] = rest[index + 1]; index += 1; }
18
+ else positional.push(arg);
19
+ }
20
+
21
+ async function loadSessions() {
22
+ const sessionsDir = path.join(dataDir, "sessions");
23
+ let files = [];
24
+ try { files = (await readdir(sessionsDir)).filter((name) => name.endsWith(".jsonl")); }
25
+ catch { fail(`no sessions directory at ${sessionsDir} (set UNREAL_MCP_PROXY_DATA_DIR)`); }
26
+ const sessions = [];
27
+ for (const file of files) {
28
+ const text = await readFile(path.join(sessionsDir, file), "utf8");
29
+ const events = text.split("\n").filter(Boolean).map((line) => JSON.parse(line));
30
+ sessions.push({ sessionId: file.replace(/\.jsonl$/, ""), events });
31
+ }
32
+ return sessions;
33
+ }
34
+
35
+ function toolOf(startedEvent) {
36
+ const body = startedEvent?.body;
37
+ const params = body?.params;
38
+ if (body?.method !== "tools/call") return { tool: body?.method ?? "mcp", toolset: null, args: null };
39
+ if (params?.name === "call_tool") {
40
+ return {
41
+ tool: params.arguments?.tool_name ?? "unknown",
42
+ toolset: params.arguments?.toolset_name ?? null,
43
+ args: params.arguments?.arguments ?? null
44
+ };
45
+ }
46
+ return { tool: params?.name ?? "unknown", toolset: null, args: params?.arguments ?? null };
47
+ }
48
+
49
+ function errorOf(event) {
50
+ if (event.type === "mcp_request_failed") return event.error ?? "connection failed";
51
+ if ((event.status ?? 0) >= 400) return `HTTP ${event.status}`;
52
+ const messages = event.body?.transport === "sse" ? event.body.events ?? [] : [event.body];
53
+ for (const message of messages) {
54
+ if (message?.error) return message.error.message ?? "MCP error";
55
+ if (message?.result?.isError) {
56
+ const text = (message.result.content ?? []).map((item) => item?.text ?? "").join("\n").trim();
57
+ return text.slice(0, 500) || "tool returned isError";
58
+ }
59
+ }
60
+ return null;
61
+ }
62
+
63
+ function buildCalls(sessions) {
64
+ const calls = [];
65
+ for (const { sessionId, events } of sessions) {
66
+ const started = new Map();
67
+ const annotations = events.filter((event) => event.type === "ai_annotation");
68
+ for (const event of events) {
69
+ if (event.type === "mcp_request_started") started.set(event.callId, event);
70
+ if (event.type === "mcp_request_completed" || event.type === "mcp_request_failed") {
71
+ const begin = started.get(event.callId);
72
+ const { tool, toolset, args } = toolOf(begin);
73
+ calls.push({
74
+ callId: event.callId, sessionId, tool, toolset, arguments: args,
75
+ startedAt: begin?.timestamp ?? event.timestamp, durationMs: event.durationMs ?? null,
76
+ status: event.type === "mcp_request_failed" ? "failed" : "completed",
77
+ error: errorOf(event),
78
+ request: begin?.body ?? null, response: event.body ?? null,
79
+ annotations: annotations.filter((note) => note.callId === event.callId)
80
+ .map(({ severity, title, summary, cause, suggestion, author }) => ({ severity, title, summary, cause, suggestion, author }))
81
+ });
82
+ }
83
+ }
84
+ }
85
+ return calls.sort((a, b) => new Date(b.startedAt) - new Date(a.startedAt));
86
+ }
87
+
88
+ function normalize(text) {
89
+ return (text ?? "").toLowerCase()
90
+ .replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g, "<uuid>")
91
+ .replace(/[a-z]:\\[^\s"'`]+/g, "<path>")
92
+ .replace(/(?:\/[\w.-]+){2,}/g, "<path>")
93
+ .replace(/0x[0-9a-f]+/g, "<hex>")
94
+ .replace(/\d+/g, "<n>")
95
+ .replace(/\s+/g, " ").trim();
96
+ }
97
+
98
+ function similarity(a, b) {
99
+ const tokens = (text) => new Set(text.split(/[^a-z<>]+/).filter((token) => token.length > 2));
100
+ const setA = tokens(a), setB = tokens(b);
101
+ if (!setA.size || !setB.size) return 0;
102
+ let shared = 0;
103
+ for (const token of setA) if (setB.has(token)) shared += 1;
104
+ return (2 * shared) / (setA.size + setB.size);
105
+ }
106
+
107
+ const out = (value) => console.log(JSON.stringify(value, null, 1));
108
+ const fail = (message) => { console.error(message); process.exit(1); };
109
+ const limit = Number(flags.limit ?? 10);
110
+
111
+ switch (command) {
112
+ case "recent-failures": {
113
+ const calls = buildCalls(await loadSessions());
114
+ out(calls.filter((call) => call.error)
115
+ .slice(0, limit)
116
+ .map(({ request, response, arguments: _args, ...call }) => call));
117
+ break;
118
+ }
119
+ case "similar-failures": {
120
+ const query = positional.join(" ");
121
+ if (!query) fail("usage: similar-failures <error text>");
122
+ const norm = normalize(query);
123
+ const calls = buildCalls(await loadSessions()).filter((call) => call.error);
124
+ const matches = calls
125
+ .map((call) => ({ score: similarity(norm, normalize(call.error)), call }))
126
+ .filter((match) => match.score > 0.3)
127
+ .sort((a, b) => b.score - a.score)
128
+ .slice(0, limit)
129
+ .map(({ score, call: { request, response, arguments: _args, ...call } }) => ({ similarity: Math.round(score * 100) / 100, ...call }));
130
+ out(matches);
131
+ break;
132
+ }
133
+ case "call-detail": {
134
+ const callId = positional[0];
135
+ if (!callId) fail("usage: call-detail <callId>");
136
+ const call = buildCalls(await loadSessions()).find((item) => item.callId.startsWith(callId));
137
+ out(call ?? { error: "not_found" });
138
+ break;
139
+ }
140
+ case "tool-stats": {
141
+ const filterTool = positional[0];
142
+ const calls = buildCalls(await loadSessions()).filter((call) => !filterTool || call.tool === filterTool);
143
+ const byTool = new Map();
144
+ for (const call of calls) {
145
+ const stats = byTool.get(call.tool) ?? { tool: call.tool, toolset: call.toolset, calls: 0, failed: 0, durations: [] };
146
+ stats.calls += 1;
147
+ if (call.error) stats.failed += 1;
148
+ if (call.durationMs != null) stats.durations.push(call.durationMs);
149
+ byTool.set(call.tool, stats);
150
+ }
151
+ out([...byTool.values()]
152
+ .sort((a, b) => b.calls - a.calls)
153
+ .map(({ durations, ...stats }) => {
154
+ const sorted = [...durations].sort((a, b) => a - b);
155
+ const at = (q) => sorted.length ? Math.round(sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))]) : null;
156
+ return { ...stats, failureRate: stats.calls ? Math.round((stats.failed / stats.calls) * 1000) / 1000 : 0, p50: at(0.5), p95: at(0.95) };
157
+ }));
158
+ break;
159
+ }
160
+ case "status": {
161
+ // Proxy health + active session + how much history is on disk.
162
+ let proxy = null;
163
+ try { proxy = await (await fetch(`${proxyUrl}/health`)).json(); } catch { /* proxy down */ }
164
+ let recorded = { sessions: 0, calls: 0, failures: 0 };
165
+ try {
166
+ const calls = buildCalls(await loadSessions());
167
+ recorded = {
168
+ sessions: new Set(calls.map((call) => call.sessionId)).size,
169
+ calls: calls.length,
170
+ failures: calls.filter((call) => call.error).length
171
+ };
172
+ } catch { /* no data dir yet */ }
173
+ out({ proxy: proxy ?? { ok: false, error: `proxy not reachable at ${proxyUrl}` }, recorded, dataDir });
174
+ break;
175
+ }
176
+ case "sessions": {
177
+ const sessions = await loadSessions();
178
+ out(sessions.map(({ sessionId, events }) => {
179
+ const calls = buildCalls([{ sessionId, events }]);
180
+ return {
181
+ sessionId,
182
+ startedAt: events[0]?.timestamp ?? null,
183
+ lastEventAt: events.at(-1)?.timestamp ?? null,
184
+ calls: calls.length,
185
+ failures: calls.filter((call) => call.error).length,
186
+ url: `${proxyUrl}/sessions/${sessionId}`
187
+ };
188
+ }).sort((a, b) => new Date(b.lastEventAt ?? 0) - new Date(a.lastEventAt ?? 0)).slice(0, limit));
189
+ break;
190
+ }
191
+ case "link": {
192
+ // Deep link for a call - hand it to a human, they see the same call in the viewer.
193
+ const prefix = positional[0];
194
+ if (!prefix) fail("usage: link <callId>");
195
+ const call = buildCalls(await loadSessions()).find((item) => item.callId.startsWith(prefix));
196
+ if (!call) { out({ error: "not_found" }); break; }
197
+ out({ callId: call.callId, tool: call.tool, url: `${proxyUrl}/sessions/${call.sessionId}?call=${call.callId}` });
198
+ break;
199
+ }
200
+ case "clear": {
201
+ // Start a new observation session (history is kept on disk).
202
+ const response = await fetch(`${proxyUrl}/api/session/clear`, { method: "POST" });
203
+ out(await response.json());
204
+ break;
205
+ }
206
+ case "annotate": {
207
+ const callId = positional[0];
208
+ if (!callId || !flags.title || !flags.summary) {
209
+ fail("usage: annotate <callId> --title <t> --summary <s> [--severity info|warn|error] [--cause <c>] [--suggestion <s>] [--author <a>]");
210
+ }
211
+ const session = await (await fetch(`${proxyUrl}/api/session`)).json();
212
+ const response = await fetch(`${proxyUrl}/api/sessions/${session.id}/annotations`, {
213
+ method: "POST",
214
+ headers: { "content-type": "application/json" },
215
+ body: JSON.stringify({
216
+ callId, severity: flags.severity ?? "info", title: flags.title, summary: flags.summary,
217
+ cause: flags.cause, suggestion: flags.suggestion, author: flags.author ?? "agent"
218
+ })
219
+ });
220
+ out({ status: response.status, ...(await response.json()) });
221
+ break;
222
+ }
223
+ default:
224
+ fail("usage: query.mjs <status|sessions|link|clear|recent-failures|similar-failures|call-detail|tool-stats|annotate> [args] [--limit N]");
225
+ }