unreal-engine-mcp-server 0.5.3 → 0.5.4
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/CHANGELOG.md +66 -0
- package/dist/automation/bridge.d.ts +1 -0
- package/dist/automation/bridge.js +62 -4
- package/dist/automation/types.d.ts +1 -0
- package/dist/constants.d.ts +2 -0
- package/dist/constants.js +2 -0
- package/dist/graphql/server.d.ts +0 -1
- package/dist/graphql/server.js +15 -16
- package/dist/index.js +1 -1
- package/dist/services/metrics-server.js +3 -3
- package/dist/tools/handlers/pipeline-handlers.js +61 -7
- package/package.json +1 -1
- package/plugins/McpAutomationBridge/Source/McpAutomationBridge/Private/McpAutomationBridge_EnvironmentHandlers.cpp +25 -1
- package/plugins/McpAutomationBridge/Source/McpAutomationBridge/Private/McpBridgeWebSocket.cpp +16 -1
- package/server.json +2 -2
- package/src/automation/bridge.ts +80 -10
- package/src/automation/types.ts +1 -0
- package/src/constants.ts +5 -0
- package/src/graphql/server.ts +23 -23
- package/src/index.ts +1 -1
- package/src/services/metrics-server.ts +4 -4
- package/src/tools/handlers/pipeline-handlers.ts +78 -12
- package/src/utils/validation.test.ts +3 -3
- package/tests/test-console-command.mjs +1 -1
- package/tests/test-runner.mjs +63 -3
- package/tests/run-unreal-tool-tests.mjs +0 -948
- package/tests/test-asset-errors.mjs +0 -35
package/src/automation/bridge.ts
CHANGED
|
@@ -6,7 +6,9 @@ import {
|
|
|
6
6
|
DEFAULT_AUTOMATION_PORT,
|
|
7
7
|
DEFAULT_NEGOTIATED_PROTOCOLS,
|
|
8
8
|
DEFAULT_HEARTBEAT_INTERVAL_MS,
|
|
9
|
-
DEFAULT_MAX_PENDING_REQUESTS
|
|
9
|
+
DEFAULT_MAX_PENDING_REQUESTS,
|
|
10
|
+
DEFAULT_MAX_QUEUED_REQUESTS,
|
|
11
|
+
MAX_WS_MESSAGE_SIZE_BYTES
|
|
10
12
|
} from '../constants.js';
|
|
11
13
|
import { createRequire } from 'node:module';
|
|
12
14
|
import {
|
|
@@ -45,6 +47,7 @@ export class AutomationBridge extends EventEmitter {
|
|
|
45
47
|
private readonly clientPort: number;
|
|
46
48
|
private readonly serverLegacyEnabled: boolean;
|
|
47
49
|
private readonly maxConcurrentConnections: number;
|
|
50
|
+
private readonly maxQueuedRequests: number;
|
|
48
51
|
|
|
49
52
|
private connectionManager: ConnectionManager;
|
|
50
53
|
private requestTracker: RequestTracker;
|
|
@@ -131,6 +134,7 @@ export class AutomationBridge extends EventEmitter {
|
|
|
131
134
|
|
|
132
135
|
const maxPendingRequests = Math.max(1, options.maxPendingRequests ?? DEFAULT_MAX_PENDING_REQUESTS);
|
|
133
136
|
const maxConcurrentConnections = Math.max(1, options.maxConcurrentConnections ?? 10);
|
|
137
|
+
this.maxQueuedRequests = Math.max(0, options.maxQueuedRequests ?? DEFAULT_MAX_QUEUED_REQUESTS);
|
|
134
138
|
|
|
135
139
|
this.clientHost = options.clientHost ?? process.env.MCP_AUTOMATION_CLIENT_HOST ?? DEFAULT_AUTOMATION_HOST;
|
|
136
140
|
this.clientPort = options.clientPort ?? sanitizePort(process.env.MCP_AUTOMATION_CLIENT_PORT) ?? DEFAULT_AUTOMATION_PORT;
|
|
@@ -185,13 +189,21 @@ export class AutomationBridge extends EventEmitter {
|
|
|
185
189
|
|
|
186
190
|
this.log.debug(`Negotiated protocols: ${JSON.stringify(this.negotiatedProtocols)}`);
|
|
187
191
|
|
|
188
|
-
|
|
189
|
-
|
|
192
|
+
const protocols = this.negotiatedProtocols.length === 1
|
|
193
|
+
? this.negotiatedProtocols[0]
|
|
194
|
+
: this.negotiatedProtocols;
|
|
190
195
|
|
|
191
196
|
this.log.debug(`Using WebSocket protocols arg: ${JSON.stringify(protocols)}`);
|
|
192
197
|
|
|
198
|
+
const headers: Record<string, string> | undefined = this.capabilityToken
|
|
199
|
+
? {
|
|
200
|
+
'X-MCP-Capability': this.capabilityToken,
|
|
201
|
+
'X-MCP-Capability-Token': this.capabilityToken
|
|
202
|
+
}
|
|
203
|
+
: undefined;
|
|
204
|
+
|
|
193
205
|
const socket = new WebSocket(url, protocols, {
|
|
194
|
-
headers
|
|
206
|
+
headers,
|
|
195
207
|
perMessageDeflate: false
|
|
196
208
|
});
|
|
197
209
|
|
|
@@ -233,10 +245,69 @@ export class AutomationBridge extends EventEmitter {
|
|
|
233
245
|
protocol: socket.protocol || null
|
|
234
246
|
});
|
|
235
247
|
|
|
236
|
-
|
|
248
|
+
const getRawDataByteLength = (data: unknown): number => {
|
|
249
|
+
if (typeof data === 'string') {
|
|
250
|
+
return Buffer.byteLength(data, 'utf8');
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (Buffer.isBuffer(data)) {
|
|
254
|
+
return data.length;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (Array.isArray(data)) {
|
|
258
|
+
return data.reduce((total, item) => total + (Buffer.isBuffer(item) ? item.length : 0), 0);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (data instanceof ArrayBuffer) {
|
|
262
|
+
return data.byteLength;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (ArrayBuffer.isView(data)) {
|
|
266
|
+
return data.byteLength;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return 0;
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
const rawDataToUtf8String = (data: unknown, byteLengthHint?: number): string => {
|
|
273
|
+
if (typeof data === 'string') {
|
|
274
|
+
return data;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (Buffer.isBuffer(data)) {
|
|
278
|
+
return data.toString('utf8');
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (Array.isArray(data)) {
|
|
282
|
+
const buffers = data.filter((item): item is Buffer => Buffer.isBuffer(item));
|
|
283
|
+
const totalLength = typeof byteLengthHint === 'number'
|
|
284
|
+
? byteLengthHint
|
|
285
|
+
: buffers.reduce((total, item) => total + item.length, 0);
|
|
286
|
+
return Buffer.concat(buffers, totalLength).toString('utf8');
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (data instanceof ArrayBuffer) {
|
|
290
|
+
return Buffer.from(data).toString('utf8');
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (ArrayBuffer.isView(data)) {
|
|
294
|
+
return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString('utf8');
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return '';
|
|
298
|
+
};
|
|
299
|
+
|
|
237
300
|
socket.on('message', (data) => {
|
|
238
301
|
try {
|
|
239
|
-
const
|
|
302
|
+
const byteLength = getRawDataByteLength(data);
|
|
303
|
+
if (byteLength > MAX_WS_MESSAGE_SIZE_BYTES) {
|
|
304
|
+
this.log.error(
|
|
305
|
+
`Received oversized message (${byteLength} bytes, max: ${MAX_WS_MESSAGE_SIZE_BYTES}). Dropping.`
|
|
306
|
+
);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const text = rawDataToUtf8String(data, byteLength);
|
|
240
311
|
this.log.debug(`[AutomationBridge Client] Received message: ${text.substring(0, 1000)}`);
|
|
241
312
|
const parsed = JSON.parse(text) as AutomationBridgeMessage;
|
|
242
313
|
this.connectionManager.updateLastMessageTime();
|
|
@@ -431,11 +502,10 @@ export class AutomationBridge extends EventEmitter {
|
|
|
431
502
|
throw new Error('Automation bridge not connected');
|
|
432
503
|
}
|
|
433
504
|
|
|
434
|
-
// Check if we need to queue (unless it's a priority request which standard ones are not)
|
|
435
|
-
// We use requestTracker directly to check limit as it's the source of truth
|
|
436
|
-
// Note: requestTracker exposes maxPendingRequests via constructor but generic check logic isn't public
|
|
437
|
-
// We assumed getPendingCount() is available
|
|
438
505
|
if (this.requestTracker.getPendingCount() >= this.requestTracker.getMaxPendingRequests()) {
|
|
506
|
+
if (this.queuedRequestItems.length >= this.maxQueuedRequests) {
|
|
507
|
+
throw new Error(`Automation bridge request queue is full (max: ${this.maxQueuedRequests}). Please retry later.`);
|
|
508
|
+
}
|
|
439
509
|
return new Promise<T>((resolve, reject) => {
|
|
440
510
|
this.queuedRequestItems.push({
|
|
441
511
|
resolve,
|
package/src/automation/types.ts
CHANGED
package/src/constants.ts
CHANGED
|
@@ -6,6 +6,7 @@ export const DEFAULT_NEGOTIATED_PROTOCOLS = ['mcp-automation'];
|
|
|
6
6
|
export const DEFAULT_HEARTBEAT_INTERVAL_MS = 10000;
|
|
7
7
|
export const DEFAULT_HANDSHAKE_TIMEOUT_MS = 5000;
|
|
8
8
|
export const DEFAULT_MAX_PENDING_REQUESTS = 25;
|
|
9
|
+
export const DEFAULT_MAX_QUEUED_REQUESTS = 100;
|
|
9
10
|
export const DEFAULT_TIME_OF_DAY = 9;
|
|
10
11
|
export const DEFAULT_SUN_INTENSITY = 10000;
|
|
11
12
|
export const DEFAULT_SKYLIGHT_INTENSITY = 1;
|
|
@@ -22,3 +23,7 @@ export const LONG_RUNNING_OP_TIMEOUT_MS = 300000;
|
|
|
22
23
|
export const CONSOLE_COMMAND_TIMEOUT_MS = 30000;
|
|
23
24
|
export const ENGINE_QUERY_TIMEOUT_MS = 15000;
|
|
24
25
|
export const CONNECTION_TIMEOUT_MS = 15000;
|
|
26
|
+
|
|
27
|
+
// Message size limits
|
|
28
|
+
export const MAX_WS_MESSAGE_SIZE_BYTES = 5 * 1024 * 1024;
|
|
29
|
+
|
package/src/graphql/server.ts
CHANGED
|
@@ -50,11 +50,32 @@ export class GraphQLServer {
|
|
|
50
50
|
return;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
const isLoopback = this.config.host === '127.0.0.1' ||
|
|
54
|
+
this.config.host === '::1' ||
|
|
55
|
+
this.config.host.toLowerCase() === 'localhost';
|
|
56
|
+
|
|
57
|
+
const allowRemote = process.env.GRAPHQL_ALLOW_REMOTE === 'true';
|
|
58
|
+
|
|
59
|
+
if (!isLoopback && !allowRemote) {
|
|
60
|
+
this.log.warn(
|
|
61
|
+
`GraphQL server is configured to bind to non-loopback host '${this.config.host}'. GraphQL is for local debugging only. ` +
|
|
62
|
+
'To allow remote binding, set GRAPHQL_ALLOW_REMOTE=true. Aborting start.'
|
|
63
|
+
);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!isLoopback && allowRemote) {
|
|
68
|
+
if (this.config.cors.origin === '*') {
|
|
69
|
+
this.log.warn(
|
|
70
|
+
"GraphQL server is binding to a remote host with permissive CORS origin '*'. " +
|
|
71
|
+
'Set GRAPHQL_CORS_ORIGIN to specific origins for production. Using permissive CORS for now.'
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
53
76
|
try {
|
|
54
|
-
// Create GraphQL schema
|
|
55
77
|
const schema = createGraphQLSchema(this.bridge, this.automationBridge);
|
|
56
78
|
|
|
57
|
-
// Create Yoga server
|
|
58
79
|
const yoga = createYoga({
|
|
59
80
|
schema,
|
|
60
81
|
graphqlEndpoint: this.config.path,
|
|
@@ -77,12 +98,10 @@ export class GraphQLServer {
|
|
|
77
98
|
}
|
|
78
99
|
});
|
|
79
100
|
|
|
80
|
-
// Create HTTP server with Yoga's request handler
|
|
81
101
|
this.server = createServer(
|
|
82
102
|
yoga as any
|
|
83
103
|
);
|
|
84
104
|
|
|
85
|
-
// Start server
|
|
86
105
|
await new Promise<void>((resolve, reject) => {
|
|
87
106
|
if (!this.server) {
|
|
88
107
|
reject(new Error('Server not initialized'));
|
|
@@ -102,9 +121,6 @@ export class GraphQLServer {
|
|
|
102
121
|
resolve();
|
|
103
122
|
});
|
|
104
123
|
});
|
|
105
|
-
|
|
106
|
-
// Setup graceful shutdown
|
|
107
|
-
this.setupShutdown();
|
|
108
124
|
} catch (error) {
|
|
109
125
|
this.log.error('Failed to start GraphQL server:', error);
|
|
110
126
|
throw error;
|
|
@@ -130,22 +146,6 @@ export class GraphQLServer {
|
|
|
130
146
|
});
|
|
131
147
|
}
|
|
132
148
|
|
|
133
|
-
private setupShutdown(): void {
|
|
134
|
-
const gracefulShutdown = async (signal: string) => {
|
|
135
|
-
this.log.info(`Received ${signal}, shutting down GraphQL server...`);
|
|
136
|
-
try {
|
|
137
|
-
await this.stop();
|
|
138
|
-
process.exit(0);
|
|
139
|
-
} catch (error) {
|
|
140
|
-
this.log.error('Error during GraphQL server shutdown:', error);
|
|
141
|
-
process.exit(1);
|
|
142
|
-
}
|
|
143
|
-
};
|
|
144
|
-
|
|
145
|
-
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
|
146
|
-
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
|
147
|
-
}
|
|
148
|
-
|
|
149
149
|
getConfig() {
|
|
150
150
|
return this.config;
|
|
151
151
|
}
|
package/src/index.ts
CHANGED
|
@@ -30,7 +30,7 @@ const DEFAULT_SERVER_NAME = typeof packageInfo.name === 'string' && packageInfo.
|
|
|
30
30
|
: 'unreal-engine-mcp';
|
|
31
31
|
const DEFAULT_SERVER_VERSION = typeof packageInfo.version === 'string' && packageInfo.version.trim().length > 0
|
|
32
32
|
? packageInfo.version
|
|
33
|
-
: '0.5.
|
|
33
|
+
: '0.5.4';
|
|
34
34
|
|
|
35
35
|
function routeStdoutLogsToStderr(): void {
|
|
36
36
|
if (!config.MCP_ROUTE_STDOUT_LOGS) {
|
|
@@ -3,7 +3,6 @@ import { HealthMonitor } from './health-monitor.js';
|
|
|
3
3
|
import { AutomationBridge } from '../automation/index.js';
|
|
4
4
|
import { Logger } from '../utils/logger.js';
|
|
5
5
|
import { wasmIntegration } from '../wasm/index.js';
|
|
6
|
-
import { DEFAULT_AUTOMATION_HOST } from '../constants.js';
|
|
7
6
|
|
|
8
7
|
interface MetricsServerOptions {
|
|
9
8
|
healthMonitor: HealthMonitor;
|
|
@@ -96,6 +95,8 @@ export function startMetricsServer(options: MetricsServerOptions): http.Server |
|
|
|
96
95
|
return null;
|
|
97
96
|
}
|
|
98
97
|
|
|
98
|
+
const host = process.env.MCP_METRICS_HOST || '127.0.0.1';
|
|
99
|
+
|
|
99
100
|
// Simple rate limiting: max 60 requests per minute per IP
|
|
100
101
|
const RATE_LIMIT_WINDOW_MS = 60000;
|
|
101
102
|
const RATE_LIMIT_MAX_REQUESTS = 60;
|
|
@@ -139,7 +140,6 @@ export function startMetricsServer(options: MetricsServerOptions): http.Server |
|
|
|
139
140
|
return;
|
|
140
141
|
}
|
|
141
142
|
|
|
142
|
-
// Apply rate limiting
|
|
143
143
|
const clientIp = req.socket.remoteAddress || 'unknown';
|
|
144
144
|
if (!checkRateLimit(clientIp)) {
|
|
145
145
|
res.statusCode = 429;
|
|
@@ -160,8 +160,8 @@ export function startMetricsServer(options: MetricsServerOptions): http.Server |
|
|
|
160
160
|
}
|
|
161
161
|
});
|
|
162
162
|
|
|
163
|
-
server.listen(port, () => {
|
|
164
|
-
logger.info(`Prometheus metrics server listening on http://${
|
|
163
|
+
server.listen(port, host, () => {
|
|
164
|
+
logger.info(`Prometheus metrics server listening on http://${host}:${port}/metrics`);
|
|
165
165
|
});
|
|
166
166
|
|
|
167
167
|
server.on('error', (err) => {
|
|
@@ -6,6 +6,67 @@ import { spawn } from 'child_process';
|
|
|
6
6
|
import path from 'path';
|
|
7
7
|
import fs from 'fs';
|
|
8
8
|
|
|
9
|
+
function validateUbtArgumentsString(extraArgs: string): void {
|
|
10
|
+
if (!extraArgs || typeof extraArgs !== 'string') {
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const forbiddenChars = ['\n', '\r', ';', '|', '`', '&&', '||', '>', '<'];
|
|
15
|
+
for (const char of forbiddenChars) {
|
|
16
|
+
if (extraArgs.includes(char)) {
|
|
17
|
+
throw new Error(
|
|
18
|
+
`UBT arguments contain forbidden character(s) and are blocked for safety. Blocked: ${JSON.stringify(char)}.`
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function tokenizeArgs(extraArgs: string): string[] {
|
|
25
|
+
if (!extraArgs) {
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const args: string[] = [];
|
|
30
|
+
let current = '';
|
|
31
|
+
let inQuotes = false;
|
|
32
|
+
let escapeNext = false;
|
|
33
|
+
|
|
34
|
+
for (let i = 0; i < extraArgs.length; i++) {
|
|
35
|
+
const ch = extraArgs[i];
|
|
36
|
+
|
|
37
|
+
if (escapeNext) {
|
|
38
|
+
current += ch;
|
|
39
|
+
escapeNext = false;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (ch === '\\') {
|
|
44
|
+
escapeNext = true;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (ch === '"') {
|
|
49
|
+
inQuotes = !inQuotes;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (!inQuotes && /\s/.test(ch)) {
|
|
54
|
+
if (current.length > 0) {
|
|
55
|
+
args.push(current);
|
|
56
|
+
current = '';
|
|
57
|
+
}
|
|
58
|
+
} else {
|
|
59
|
+
current += ch;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (current.length > 0) {
|
|
64
|
+
args.push(current);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return args;
|
|
68
|
+
}
|
|
69
|
+
|
|
9
70
|
export async function handlePipelineTools(action: string, args: PipelineArgs, tools: ITools) {
|
|
10
71
|
switch (action) {
|
|
11
72
|
case 'run_ubt': {
|
|
@@ -18,8 +79,9 @@ export async function handlePipelineTools(action: string, args: PipelineArgs, to
|
|
|
18
79
|
throw new Error('Target is required for run_ubt');
|
|
19
80
|
}
|
|
20
81
|
|
|
21
|
-
|
|
22
|
-
|
|
82
|
+
validateUbtArgumentsString(extraArgs);
|
|
83
|
+
|
|
84
|
+
let ubtPath = 'UnrealBuildTool';
|
|
23
85
|
const enginePath = process.env.UE_ENGINE_PATH || process.env.UNREAL_ENGINE_PATH;
|
|
24
86
|
|
|
25
87
|
if (enginePath) {
|
|
@@ -38,10 +100,8 @@ export async function handlePipelineTools(action: string, args: PipelineArgs, to
|
|
|
38
100
|
throw new Error('UE_PROJECT_PATH environment variable is not set and no projectPath argument was provided.');
|
|
39
101
|
}
|
|
40
102
|
|
|
41
|
-
// If projectPath points to a .uproject file, use it. If it's a directory, look for a .uproject file.
|
|
42
103
|
let uprojectFile = projectPath;
|
|
43
104
|
if (!uprojectFile.endsWith('.uproject')) {
|
|
44
|
-
// Find first .uproject in the directory
|
|
45
105
|
try {
|
|
46
106
|
const files = fs.readdirSync(projectPath);
|
|
47
107
|
const found = files.find(f => f.endsWith('.uproject'));
|
|
@@ -53,16 +113,19 @@ export async function handlePipelineTools(action: string, args: PipelineArgs, to
|
|
|
53
113
|
}
|
|
54
114
|
}
|
|
55
115
|
|
|
116
|
+
const projectArg = `-Project="${uprojectFile}"`;
|
|
117
|
+
const extraTokens = tokenizeArgs(extraArgs);
|
|
118
|
+
|
|
56
119
|
const cmdArgs = [
|
|
57
120
|
target,
|
|
58
121
|
platform,
|
|
59
122
|
configuration,
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
]
|
|
123
|
+
projectArg,
|
|
124
|
+
...extraTokens
|
|
125
|
+
];
|
|
63
126
|
|
|
64
127
|
return new Promise((resolve) => {
|
|
65
|
-
const child = spawn(ubtPath, cmdArgs, { shell:
|
|
128
|
+
const child = spawn(ubtPath, cmdArgs, { shell: false });
|
|
66
129
|
|
|
67
130
|
const MAX_OUTPUT_SIZE = 20 * 1024; // 20KB cap
|
|
68
131
|
let stdout = '';
|
|
@@ -91,12 +154,14 @@ export async function handlePipelineTools(action: string, args: PipelineArgs, to
|
|
|
91
154
|
? '\n[Output truncated for response payload]'
|
|
92
155
|
: '';
|
|
93
156
|
|
|
157
|
+
const quotedArgs = cmdArgs.map(arg => arg.includes(' ') ? `"${arg}"` : arg);
|
|
158
|
+
|
|
94
159
|
if (code === 0) {
|
|
95
160
|
resolve({
|
|
96
161
|
success: true,
|
|
97
162
|
message: 'UnrealBuildTool finished successfully',
|
|
98
163
|
output: stdout + truncatedNote,
|
|
99
|
-
command: `${ubtPath} ${
|
|
164
|
+
command: `${ubtPath} ${quotedArgs.join(' ')}`
|
|
100
165
|
});
|
|
101
166
|
} else {
|
|
102
167
|
resolve({
|
|
@@ -105,23 +170,24 @@ export async function handlePipelineTools(action: string, args: PipelineArgs, to
|
|
|
105
170
|
message: `UnrealBuildTool failed with code ${code}`,
|
|
106
171
|
output: stdout + truncatedNote,
|
|
107
172
|
errorOutput: stderr + truncatedNote,
|
|
108
|
-
command: `${ubtPath} ${
|
|
173
|
+
command: `${ubtPath} ${quotedArgs.join(' ')}`
|
|
109
174
|
});
|
|
110
175
|
}
|
|
111
176
|
});
|
|
112
177
|
|
|
113
178
|
child.on('error', (err) => {
|
|
179
|
+
const quotedArgs = cmdArgs.map(arg => arg.includes(' ') ? `"${arg}"` : arg);
|
|
180
|
+
|
|
114
181
|
resolve({
|
|
115
182
|
success: false,
|
|
116
183
|
error: 'SPAWN_FAILED',
|
|
117
184
|
message: `Failed to spawn UnrealBuildTool: ${err.message}`,
|
|
118
|
-
command: `${ubtPath} ${
|
|
185
|
+
command: `${ubtPath} ${quotedArgs.join(' ')}`
|
|
119
186
|
});
|
|
120
187
|
});
|
|
121
188
|
});
|
|
122
189
|
}
|
|
123
190
|
default:
|
|
124
|
-
// Fallback to automation bridge if we add more actions later that are bridge-supported
|
|
125
191
|
const res = await executeAutomationRequest(tools, 'manage_pipeline', { ...args, subAction: action }, 'Automation bridge not available for manage_pipeline');
|
|
126
192
|
return cleanObject(res);
|
|
127
193
|
}
|
|
@@ -59,9 +59,9 @@ describe('sanitizePath', () => {
|
|
|
59
59
|
});
|
|
60
60
|
|
|
61
61
|
it('sanitizes path segments with dots', () => {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
62
|
+
expect(() => sanitizePath('/Game/../MyAsset')).toThrow(
|
|
63
|
+
'Path traversal (..) is not allowed'
|
|
64
|
+
);
|
|
65
65
|
});
|
|
66
66
|
});
|
|
67
67
|
|
|
@@ -43,7 +43,7 @@ const testCases = [
|
|
|
43
43
|
scenario: "Edge: Very long safe command",
|
|
44
44
|
toolName: "system_control",
|
|
45
45
|
arguments: { action: "console_command", command: "stat fps; stat gpu; stat memory" },
|
|
46
|
-
expected: "
|
|
46
|
+
expected: "blocked|command_blocked|blocked for safety"
|
|
47
47
|
},
|
|
48
48
|
{
|
|
49
49
|
scenario: "Warning: Unknown command",
|
package/tests/test-runner.mjs
CHANGED
|
@@ -20,6 +20,66 @@ let serverArgs = process.env.UNREAL_MCP_SERVER_ARGS ? process.env.UNREAL_MCP_SER
|
|
|
20
20
|
const serverCwd = process.env.UNREAL_MCP_SERVER_CWD ?? repoRoot;
|
|
21
21
|
const serverEnv = Object.assign({}, process.env);
|
|
22
22
|
|
|
23
|
+
const DEFAULT_RESPONSE_LOG_MAX_CHARS = 6000; // default max chars
|
|
24
|
+
const RESPONSE_LOGGING_ENABLED = process.env.UNREAL_MCP_TEST_LOG_RESPONSES !== '0';
|
|
25
|
+
|
|
26
|
+
function clampString(value, maxChars) {
|
|
27
|
+
if (typeof value !== 'string') return '';
|
|
28
|
+
if (value.length <= maxChars) return value;
|
|
29
|
+
return value.slice(0, maxChars) + `\n... (truncated, ${value.length - maxChars} chars omitted)`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function tryParseJson(text) {
|
|
33
|
+
if (typeof text !== 'string') return null;
|
|
34
|
+
try {
|
|
35
|
+
return JSON.parse(text);
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function normalizeMcpResponse(response) {
|
|
42
|
+
const normalized = {
|
|
43
|
+
isError: Boolean(response?.isError),
|
|
44
|
+
structuredContent: response?.structuredContent ?? null,
|
|
45
|
+
contentText: '',
|
|
46
|
+
content: response?.content ?? undefined
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
if (normalized.structuredContent === null && Array.isArray(response?.content)) {
|
|
50
|
+
for (const entry of response.content) {
|
|
51
|
+
if (entry?.type !== 'text' || typeof entry.text !== 'string') continue;
|
|
52
|
+
const parsed = tryParseJson(entry.text);
|
|
53
|
+
if (parsed !== null) {
|
|
54
|
+
normalized.structuredContent = parsed;
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (Array.isArray(response?.content) && response.content.length > 0) {
|
|
61
|
+
normalized.contentText = response.content
|
|
62
|
+
.map((entry) => (entry && typeof entry.text === 'string' ? entry.text : ''))
|
|
63
|
+
.filter((text) => text.length > 0)
|
|
64
|
+
.join('\n');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return normalized;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function logMcpResponse(toolName, normalizedResponse) {
|
|
71
|
+
const maxChars = Number(process.env.UNREAL_MCP_TEST_RESPONSE_MAX_CHARS ?? DEFAULT_RESPONSE_LOG_MAX_CHARS);
|
|
72
|
+
const payload = {
|
|
73
|
+
isError: normalizedResponse.isError,
|
|
74
|
+
structuredContent: normalizedResponse.structuredContent,
|
|
75
|
+
contentText: normalizedResponse.contentText,
|
|
76
|
+
content: normalizedResponse.content
|
|
77
|
+
};
|
|
78
|
+
const json = JSON.stringify(payload, null, 2);
|
|
79
|
+
console.log(`[MCP RESPONSE] ${toolName}:`);
|
|
80
|
+
console.log(clampString(json, Number.isFinite(maxChars) && maxChars > 0 ? maxChars : DEFAULT_RESPONSE_LOG_MAX_CHARS));
|
|
81
|
+
}
|
|
82
|
+
|
|
23
83
|
function formatResultLine(testCase, status, detail, durationMs) {
|
|
24
84
|
const durationText = typeof durationMs === 'number' ? ` (${durationMs.toFixed(1)} ms)` : '';
|
|
25
85
|
return `[${status.toUpperCase()}] ${testCase.scenario}${durationText}${detail ? ` => ${detail}` : ''}`;
|
|
@@ -515,13 +575,13 @@ export async function runToolTests(toolName, testCases) {
|
|
|
515
575
|
}
|
|
516
576
|
}
|
|
517
577
|
const normalizedResponse = { ...response, structuredContent };
|
|
578
|
+
if (RESPONSE_LOGGING_ENABLED) {
|
|
579
|
+
logMcpResponse(testCase.toolName + " :: " + testCase.scenario, normalizeMcpResponse(normalizedResponse));
|
|
580
|
+
}
|
|
518
581
|
const { passed, reason } = evaluateExpectation(testCase, normalizedResponse);
|
|
519
582
|
|
|
520
583
|
if (!passed) {
|
|
521
584
|
console.log(`[FAILED] ${testCase.scenario} (${durationMs.toFixed(1)} ms) => ${reason}`);
|
|
522
|
-
if (normalizedResponse) {
|
|
523
|
-
console.log(`[DEBUG] Full response for ${testCase.scenario}:`, JSON.stringify(normalizedResponse, null, 2));
|
|
524
|
-
}
|
|
525
585
|
results.push({
|
|
526
586
|
scenario: testCase.scenario,
|
|
527
587
|
toolName: testCase.toolName,
|