unreal-engine-mcp-server 0.4.5 → 0.4.7

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.
@@ -0,0 +1,267 @@
1
+ import { UnrealBridge } from '../unreal-bridge.js';
2
+ import { loadEnv } from '../types/env.js';
3
+ import { Logger } from '../utils/logger.js';
4
+ import { promises as fs } from 'fs';
5
+ import path from 'path';
6
+
7
+ type ReadParams = {
8
+ filterCategory?: string[]
9
+ filterLevel?: 'Error' | 'Warning' | 'Log' | 'Verbose' | 'VeryVerbose' | 'All'
10
+ lines?: number
11
+ logPath?: string
12
+ includePrefixes?: string[]
13
+ excludeCategories?: string[]
14
+ }
15
+
16
+ type Entry = {
17
+ timestamp?: string
18
+ category?: string
19
+ level?: string
20
+ message: string
21
+ }
22
+
23
+ export class LogTools {
24
+ private env = loadEnv();
25
+ private log = new Logger('LogTools');
26
+ private cachedLogPath?: string;
27
+ constructor(private bridge: UnrealBridge) {}
28
+
29
+ async readOutputLog(params: ReadParams) {
30
+ const target = await this.resolveLogPath(params.logPath);
31
+ if (!target) {
32
+ return { success: false, error: 'Log file not found' };
33
+ }
34
+ const maxLines = typeof params.lines === 'number' && params.lines > 0 ? Math.min(params.lines, 2000) : 200;
35
+ let text = '';
36
+ try {
37
+ text = await this.tailFile(target, maxLines);
38
+ } catch (err: any) {
39
+ return { success: false, error: String(err?.message || err) };
40
+ }
41
+ const rawLines = text.split(/\r?\n/).filter(l => l.length > 0);
42
+ const parsed: Entry[] = rawLines.map(l => this.parseLine(l));
43
+ const mappedLevel = params.filterLevel || 'All';
44
+ const includeCats = Array.isArray(params.filterCategory) && params.filterCategory.length ? new Set(params.filterCategory) : undefined;
45
+ const includePrefixes = Array.isArray(params.includePrefixes) && params.includePrefixes.length ? params.includePrefixes : undefined;
46
+ const excludeCats = Array.isArray(params.excludeCategories) && params.excludeCategories.length ? new Set(params.excludeCategories) : undefined;
47
+ const filtered = parsed.filter(e => {
48
+ if (!e) return false;
49
+ if (mappedLevel && mappedLevel !== 'All') {
50
+ const lv = (e.level || 'Log');
51
+ if (lv === 'Display') {
52
+ if (mappedLevel !== 'Log') return false;
53
+ } else if (lv !== mappedLevel) {
54
+ return false;
55
+ }
56
+ }
57
+ if (includeCats && e.category && !includeCats.has(e.category)) return false;
58
+ if (includePrefixes && includePrefixes.length && e.category) {
59
+ if (!includePrefixes.some(p => (e.category ?? '').startsWith(p))) return false;
60
+ }
61
+ if (excludeCats && e.category && excludeCats.has(e.category)) return false;
62
+ return true;
63
+ });
64
+ const includeInternal = Boolean(
65
+ (includeCats && includeCats.has('LogPython')) ||
66
+ (includePrefixes && includePrefixes.some(p => 'LogPython'.startsWith(p)))
67
+ );
68
+ const sanitized = includeInternal ? filtered : filtered.filter(entry => !this.isInternalLogEntry(entry));
69
+ return { success: true, logPath: target.replace(/\\/g, '/'), entries: sanitized, filteredCount: sanitized.length };
70
+ }
71
+
72
+ private async resolveLogPath(override?: string): Promise<string | undefined> {
73
+ if (override && typeof override === 'string' && override.trim()) {
74
+ try {
75
+ const st = await fs.stat(override);
76
+ if (st.isFile()) {
77
+ return this.cacheLogPath(path.resolve(override));
78
+ }
79
+ } catch {}
80
+ }
81
+
82
+ if (this.cachedLogPath && (await this.fileExists(this.cachedLogPath))) {
83
+ return this.cachedLogPath;
84
+ }
85
+
86
+ const envLog = await this.resolveFromProjectEnv();
87
+ if (envLog) {
88
+ return envLog;
89
+ }
90
+
91
+ if (this.bridge.isConnected) {
92
+ try {
93
+ const script = `
94
+ import unreal, json, os
95
+ paths = []
96
+ try:
97
+ d = unreal.Paths.project_log_dir()
98
+ if d:
99
+ paths.append(os.path.abspath(d))
100
+ except Exception:
101
+ pass
102
+ try:
103
+ sd = unreal.Paths.project_saved_dir()
104
+ if sd:
105
+ p = os.path.join(sd, 'Logs')
106
+ paths.append(os.path.abspath(p))
107
+ except Exception:
108
+ pass
109
+ try:
110
+ pf = unreal.Paths.get_project_file_path()
111
+ if pf:
112
+ pd = os.path.dirname(pf)
113
+ p = os.path.join(pd, 'Saved', 'Logs')
114
+ paths.append(os.path.abspath(p))
115
+ except Exception:
116
+ pass
117
+ all_logs = []
118
+ for base in paths:
119
+ try:
120
+ if os.path.isdir(base):
121
+ for name in os.listdir(base):
122
+ if name.lower().endswith('.log'):
123
+ fp = os.path.join(base, name)
124
+ try:
125
+ m = os.path.getmtime(fp)
126
+ all_logs.append({'p': fp, 'm': m})
127
+ except Exception:
128
+ pass
129
+ except Exception:
130
+ pass
131
+ all_logs.sort(key=lambda x: x['m'], reverse=True)
132
+ print('RESULT:' + json.dumps({'dirs': paths, 'logs': all_logs}))
133
+ `.trim();
134
+ const res = await this.bridge.executePythonWithResult(script);
135
+ const logs = Array.isArray(res?.logs) ? res.logs : [];
136
+ for (const entry of logs) {
137
+ const p = typeof entry?.p === 'string' ? entry.p : undefined;
138
+ if (p && p.trim()) return this.cacheLogPath(p);
139
+ }
140
+ } catch {}
141
+ }
142
+ const fallback = await this.findLatestLogInDir(path.join(process.cwd(), 'Saved', 'Logs'));
143
+ if (fallback) {
144
+ return fallback;
145
+ }
146
+ return undefined;
147
+ }
148
+
149
+ private async resolveFromProjectEnv(): Promise<string | undefined> {
150
+ const projectPath = this.env.UE_PROJECT_PATH;
151
+ if (projectPath && typeof projectPath === 'string' && projectPath.trim()) {
152
+ const projectDir = path.dirname(projectPath);
153
+ const logsDir = path.join(projectDir, 'Saved', 'Logs');
154
+ const envLog = await this.findLatestLogInDir(logsDir);
155
+ if (envLog) {
156
+ return envLog;
157
+ }
158
+ }
159
+ return undefined;
160
+ }
161
+
162
+ private async findLatestLogInDir(dir: string): Promise<string | undefined> {
163
+ if (!dir) return undefined;
164
+ try {
165
+ const entries = await fs.readdir(dir);
166
+ const candidates: { p: string; m: number }[] = [];
167
+ for (const name of entries) {
168
+ if (!name.toLowerCase().endsWith('.log')) continue;
169
+ const fp = path.join(dir, name);
170
+ try {
171
+ const st = await fs.stat(fp);
172
+ candidates.push({ p: fp, m: st.mtimeMs });
173
+ } catch {}
174
+ }
175
+ if (candidates.length) {
176
+ candidates.sort((a, b) => b.m - a.m);
177
+ return this.cacheLogPath(candidates[0].p);
178
+ }
179
+ } catch {}
180
+ return undefined;
181
+ }
182
+
183
+ private async fileExists(filePath: string): Promise<boolean> {
184
+ try {
185
+ const st = await fs.stat(filePath);
186
+ return st.isFile();
187
+ } catch {
188
+ return false;
189
+ }
190
+ }
191
+
192
+ private cacheLogPath(p: string): string {
193
+ this.cachedLogPath = p;
194
+ return p;
195
+ }
196
+
197
+ private async tailFile(filePath: string, maxLines: number): Promise<string> {
198
+ const handle = await fs.open(filePath, 'r');
199
+ try {
200
+ const stat = await handle.stat();
201
+ const chunkSize = 128 * 1024;
202
+ let position = stat.size;
203
+ let remaining = '';
204
+ const lines: string[] = [];
205
+ while (position > 0 && lines.length < maxLines) {
206
+ const readSize = Math.min(chunkSize, position);
207
+ position -= readSize;
208
+ const buf = Buffer.alloc(readSize);
209
+ await handle.read(buf, 0, readSize, position);
210
+ remaining = buf.toString('utf8') + remaining;
211
+ const parts = remaining.split(/\r?\n/);
212
+ remaining = parts.shift() || '';
213
+ while (parts.length) {
214
+ const line = parts.pop() as string;
215
+ if (line === undefined) break;
216
+ if (line.length === 0) continue;
217
+ lines.unshift(line);
218
+ if (lines.length >= maxLines) break;
219
+ }
220
+ }
221
+ if (lines.length < maxLines && remaining) {
222
+ lines.unshift(remaining);
223
+ }
224
+ return lines.slice(0, maxLines).join('\n');
225
+ } finally {
226
+ try { await handle.close(); } catch {}
227
+ }
228
+ }
229
+
230
+ private parseLine(line: string): Entry {
231
+ const m1 = line.match(/^\[?(\d{4}\.\d{2}\.\d{2}-\d{2}\.\d{2}\.\d{2}:\d+)\]?\s*\[(.*?)\]\s*(.*)$/);
232
+ if (m1) {
233
+ const rest = m1[3];
234
+ const m2 = rest.match(/^(\w+):\s*(Error|Warning|Display|Log|Verbose|VeryVerbose):\s*(.*)$/);
235
+ if (m2) {
236
+ return { timestamp: m1[1], category: m2[1], level: m2[2] === 'Display' ? 'Log' : m2[2], message: m2[3] };
237
+ }
238
+ const m3 = rest.match(/^(\w+):\s*(.*)$/);
239
+ if (m3) {
240
+ return { timestamp: m1[1], category: m3[1], level: 'Log', message: m3[2] };
241
+ }
242
+ return { timestamp: m1[1], message: rest };
243
+ }
244
+ const m = line.match(/^(\w+):\s*(Error|Warning|Display|Log|Verbose|VeryVerbose):\s*(.*)$/);
245
+ if (m) {
246
+ return { category: m[1], level: m[2] === 'Display' ? 'Log' : m[2], message: m[3] };
247
+ }
248
+ const mAlt = line.match(/^(\w+):\s*(.*)$/);
249
+ if (mAlt) {
250
+ return { category: mAlt[1], level: 'Log', message: mAlt[2] };
251
+ }
252
+ return { message: line };
253
+ }
254
+
255
+ private isInternalLogEntry(entry: Entry): boolean {
256
+ if (!entry) return false;
257
+ const category = entry.category?.toLowerCase() || '';
258
+ const message = entry.message?.trim() || '';
259
+ if (category === 'logpython' && message.startsWith('RESULT:')) {
260
+ return true;
261
+ }
262
+ if (!entry.category && message.startsWith('[') && message.includes('LogPython: RESULT:')) {
263
+ return true;
264
+ }
265
+ return false;
266
+ }
267
+ }
@@ -98,6 +98,12 @@ export interface SystemControlResponse extends BaseToolResponse {
98
98
  soundPlaying?: boolean;
99
99
  widgetPath?: string;
100
100
  widgetVisible?: boolean;
101
+ imagePath?: string;
102
+ imageBase64?: string;
103
+ pid?: number;
104
+ logPath?: string;
105
+ entries?: Array<{ timestamp?: string; category?: string; level?: string; message: string }>;
106
+ filteredCount?: number;
101
107
  }
102
108
 
103
109
  // Console Command Types
@@ -174,7 +180,7 @@ export type AnimationAction = 'create_animation_bp' | 'play_montage' | 'setup_ra
174
180
  export type EffectAction = 'particle' | 'niagara' | 'debug_shape';
175
181
  export type BlueprintAction = 'create' | 'add_component';
176
182
  export type EnvironmentAction = 'create_landscape' | 'sculpt' | 'add_foliage' | 'paint_foliage';
177
- export type SystemAction = 'profile' | 'show_fps' | 'set_quality' | 'play_sound' | 'create_widget' | 'show_widget';
183
+ export type SystemAction = 'profile' | 'show_fps' | 'set_quality' | 'play_sound' | 'create_widget' | 'show_widget' | 'screenshot' | 'engine_start' | 'engine_quit' | 'read_log';
178
184
  export type VerificationAction = 'foliage_type_exists' | 'foliage_instances_near' | 'landscape_exists' | 'quality_level';
179
185
 
180
186
  // Consolidated tool parameter types
@@ -282,6 +288,15 @@ export interface ConsolidatedToolParams {
282
288
  widgetName?: string;
283
289
  widgetType?: string;
284
290
  visible?: boolean;
291
+ resolution?: string;
292
+ projectPath?: string;
293
+ editorExe?: string;
294
+ filter_category?: string | string[];
295
+ filter_level?: 'Error' | 'Warning' | 'Log' | 'Verbose' | 'VeryVerbose' | 'All';
296
+ lines?: number;
297
+ log_path?: string;
298
+ include_prefixes?: string[];
299
+ exclude_categories?: string[];
285
300
  };
286
301
 
287
302
  console_command: {
@@ -4,6 +4,69 @@ import { cleanObject } from './safe-json.js';
4
4
 
5
5
  const log = new Logger('ResponseValidator');
6
6
 
7
+ function isRecord(value: unknown): value is Record<string, unknown> {
8
+ return !!value && typeof value === 'object' && !Array.isArray(value);
9
+ }
10
+
11
+ function normalizeText(text: string): string {
12
+ return text.replace(/\s+/g, ' ').trim();
13
+ }
14
+
15
+ function buildSummaryText(toolName: string, payload: unknown): string {
16
+ if (typeof payload === 'string') {
17
+ const normalized = payload.trim();
18
+ return normalized || `${toolName} responded`;
19
+ }
20
+
21
+ if (typeof payload === 'number' || typeof payload === 'bigint' || typeof payload === 'boolean') {
22
+ return `${toolName} responded: ${payload}`;
23
+ }
24
+
25
+ if (!isRecord(payload)) {
26
+ return `${toolName} responded`;
27
+ }
28
+
29
+ const parts: string[] = [];
30
+ const message = typeof payload.message === 'string' ? normalizeText(payload.message) : '';
31
+ const error = typeof payload.error === 'string' ? normalizeText(payload.error) : '';
32
+ const success = typeof payload.success === 'boolean' ? (payload.success ? 'success' : 'failed') : '';
33
+ const path = typeof payload.path === 'string' ? payload.path : '';
34
+ const name = typeof payload.name === 'string' ? payload.name : '';
35
+ const warningCount = Array.isArray(payload.warnings) ? payload.warnings.length : 0;
36
+
37
+ if (message) parts.push(message);
38
+ if (error && (!message || !message.includes(error))) parts.push(`error: ${error}`);
39
+ if (success) parts.push(success);
40
+ if (path) parts.push(`path: ${path}`);
41
+ if (name) parts.push(`name: ${name}`);
42
+ if (warningCount > 0) parts.push(`warnings: ${warningCount}`);
43
+
44
+ const summary = isRecord(payload.summary) ? payload.summary : undefined;
45
+ if (summary) {
46
+ const summaryParts: string[] = [];
47
+ for (const [key, value] of Object.entries(summary)) {
48
+ if (typeof value === 'number' || typeof value === 'string') {
49
+ summaryParts.push(`${key}: ${value}`);
50
+ }
51
+ if (summaryParts.length >= 3) break;
52
+ }
53
+ if (summaryParts.length) {
54
+ parts.push(`summary(${summaryParts.join(', ')})`);
55
+ }
56
+ }
57
+
58
+ if (parts.length === 0) {
59
+ const keys = Object.keys(payload).slice(0, 3);
60
+ if (keys.length) {
61
+ return `${toolName} responded (${keys.join(', ')})`;
62
+ }
63
+ }
64
+
65
+ return parts.length > 0
66
+ ? parts.join(' | ')
67
+ : `${toolName} responded`;
68
+ }
69
+
7
70
  /**
8
71
  * Response Validator for MCP Tool Outputs
9
72
  * Validates tool responses against their defined output schemas
@@ -148,14 +211,10 @@ export class ResponseValidator {
148
211
  }
149
212
 
150
213
  // Otherwise, wrap structured result into MCP content
151
- let text: string;
152
- try {
153
- // Pretty-print small objects for readability
154
- text = typeof response === 'string'
155
- ? response
156
- : JSON.stringify(response ?? { success: true }, null, 2);
157
- } catch (_e) {
158
- text = String(response);
214
+ const summarySource = structuredPayload !== undefined ? structuredPayload : response;
215
+ let text = buildSummaryText(toolName, summarySource);
216
+ if (!text || !text.trim()) {
217
+ text = buildSummaryText(toolName, response);
159
218
  }
160
219
 
161
220
  const wrapped = {