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/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,72 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
---
|
|
9
9
|
|
|
10
|
+
## ๐ท๏ธ [0.5.4] - 2025-12-27
|
|
11
|
+
|
|
12
|
+
> [!IMPORTANT]
|
|
13
|
+
> ### ๐ก๏ธ Security Release
|
|
14
|
+
> This release focuses on **security hardening** and **defensive improvements** across the entire stack, including command injection prevention, network isolation, and resource management.
|
|
15
|
+
|
|
16
|
+
### ๐ก๏ธ Security & Command Hardening
|
|
17
|
+
|
|
18
|
+
<details>
|
|
19
|
+
<summary><b>UBT Validation & Safe Execution</b></summary>
|
|
20
|
+
|
|
21
|
+
| Feature | Description |
|
|
22
|
+
|---------|-------------|
|
|
23
|
+
| **UBT Argument Validation** | Added `validateUbtArgumentsString` and `tokenizeArgs` to block dangerous characters (`;`, `|`, backticks) |
|
|
24
|
+
| **Safe Process Spawning** | Updated child process spawning to use `shell: false`, preventing shell injection attacks |
|
|
25
|
+
| **Console Command Validation** | Implemented strict input validation for the Unreal Automation Bridge to block chained or multi-line commands |
|
|
26
|
+
| **Argument Quoting** | Improved logging and execution logic to correctly quote arguments containing spaces |
|
|
27
|
+
|
|
28
|
+
</details>
|
|
29
|
+
|
|
30
|
+
### ๐ Network & Host Binding
|
|
31
|
+
|
|
32
|
+
<details>
|
|
33
|
+
<summary><b>Localhost Default & Remote Configuration</b></summary>
|
|
34
|
+
|
|
35
|
+
| Feature | Description |
|
|
36
|
+
|---------|-------------|
|
|
37
|
+
| **Localhost Default** | WebSocket, Metrics, and GraphQL servers now bind to `127.0.0.1` by default |
|
|
38
|
+
| **Remote Exposure Prevention** | Prevents accidental remote exposure of services |
|
|
39
|
+
| **GRAPHQL_ALLOW_REMOTE** | Added environment variable check for explicit remote binding configuration |
|
|
40
|
+
| **Security Warnings** | Warnings logged for unsafe/permissive network settings |
|
|
41
|
+
|
|
42
|
+
</details>
|
|
43
|
+
|
|
44
|
+
### ๐ฆ Resource Management
|
|
45
|
+
|
|
46
|
+
<details>
|
|
47
|
+
<summary><b>Rate Limiting & Queue Management</b></summary>
|
|
48
|
+
|
|
49
|
+
| Feature | Description |
|
|
50
|
+
|---------|-------------|
|
|
51
|
+
| **IP-Based Rate Limiting** | Implemented rate limiting on the metrics server |
|
|
52
|
+
| **Queue Limits** | Introduced `maxQueuedRequests` to automation bridge to prevent memory exhaustion |
|
|
53
|
+
| **Message Size Enforcement** | Enforced `MAX_WS_MESSAGE_SIZE_BYTES` for WebSocket connections to reject oversized payloads |
|
|
54
|
+
|
|
55
|
+
</details>
|
|
56
|
+
|
|
57
|
+
### ๐งช Testing & Cleanup
|
|
58
|
+
|
|
59
|
+
<details>
|
|
60
|
+
<summary><b>Test Updates & File Cleanup</b></summary>
|
|
61
|
+
|
|
62
|
+
| Change | Description |
|
|
63
|
+
|--------|-------------|
|
|
64
|
+
| **Path Sanitization Tests** | Modified validation tests to verify path sanitization and expect errors for traversal attempts |
|
|
65
|
+
| **Removed Legacy Tests** | Removed outdated test files (`run-unreal-tool-tests.mjs`, `test-asset-errors.mjs`) |
|
|
66
|
+
| **Response Logging** | Implemented better response logging in the test runner |
|
|
67
|
+
|
|
68
|
+
</details>
|
|
69
|
+
|
|
70
|
+
### ๐ Dependencies
|
|
71
|
+
|
|
72
|
+
- **dependencies group**: Bumped 2 updates via @dependabot ([#33](https://github.com/ChiR24/Unreal_mcp/pull/33))
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
10
76
|
## ๐ท๏ธ [0.5.3] - 2025-12-21
|
|
11
77
|
|
|
12
78
|
> [!IMPORTANT]
|
|
@@ -13,6 +13,7 @@ export declare class AutomationBridge extends EventEmitter {
|
|
|
13
13
|
private readonly clientPort;
|
|
14
14
|
private readonly serverLegacyEnabled;
|
|
15
15
|
private readonly maxConcurrentConnections;
|
|
16
|
+
private readonly maxQueuedRequests;
|
|
16
17
|
private connectionManager;
|
|
17
18
|
private requestTracker;
|
|
18
19
|
private handshakeHandler;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { EventEmitter } from 'node:events';
|
|
2
2
|
import { WebSocket } from 'ws';
|
|
3
3
|
import { Logger } from '../utils/logger.js';
|
|
4
|
-
import { DEFAULT_AUTOMATION_HOST, DEFAULT_AUTOMATION_PORT, DEFAULT_NEGOTIATED_PROTOCOLS, DEFAULT_HEARTBEAT_INTERVAL_MS, DEFAULT_MAX_PENDING_REQUESTS } from '../constants.js';
|
|
4
|
+
import { DEFAULT_AUTOMATION_HOST, DEFAULT_AUTOMATION_PORT, DEFAULT_NEGOTIATED_PROTOCOLS, DEFAULT_HEARTBEAT_INTERVAL_MS, DEFAULT_MAX_PENDING_REQUESTS, DEFAULT_MAX_QUEUED_REQUESTS, MAX_WS_MESSAGE_SIZE_BYTES } from '../constants.js';
|
|
5
5
|
import { createRequire } from 'node:module';
|
|
6
6
|
import { ConnectionManager } from './connection-manager.js';
|
|
7
7
|
import { RequestTracker } from './request-tracker.js';
|
|
@@ -31,6 +31,7 @@ export class AutomationBridge extends EventEmitter {
|
|
|
31
31
|
clientPort;
|
|
32
32
|
serverLegacyEnabled;
|
|
33
33
|
maxConcurrentConnections;
|
|
34
|
+
maxQueuedRequests;
|
|
34
35
|
connectionManager;
|
|
35
36
|
requestTracker;
|
|
36
37
|
handshakeHandler;
|
|
@@ -107,6 +108,7 @@ export class AutomationBridge extends EventEmitter {
|
|
|
107
108
|
: 0;
|
|
108
109
|
const maxPendingRequests = Math.max(1, options.maxPendingRequests ?? DEFAULT_MAX_PENDING_REQUESTS);
|
|
109
110
|
const maxConcurrentConnections = Math.max(1, options.maxConcurrentConnections ?? 10);
|
|
111
|
+
this.maxQueuedRequests = Math.max(0, options.maxQueuedRequests ?? DEFAULT_MAX_QUEUED_REQUESTS);
|
|
110
112
|
this.clientHost = options.clientHost ?? process.env.MCP_AUTOMATION_CLIENT_HOST ?? DEFAULT_AUTOMATION_HOST;
|
|
111
113
|
this.clientPort = options.clientPort ?? sanitizePort(process.env.MCP_AUTOMATION_CLIENT_PORT) ?? DEFAULT_AUTOMATION_PORT;
|
|
112
114
|
this.maxConcurrentConnections = maxConcurrentConnections;
|
|
@@ -137,10 +139,18 @@ export class AutomationBridge extends EventEmitter {
|
|
|
137
139
|
const url = `ws://${this.clientHost}:${this.clientPort}`;
|
|
138
140
|
this.log.info(`Connecting to Unreal Engine automation server at ${url}`);
|
|
139
141
|
this.log.debug(`Negotiated protocols: ${JSON.stringify(this.negotiatedProtocols)}`);
|
|
140
|
-
const protocols =
|
|
142
|
+
const protocols = this.negotiatedProtocols.length === 1
|
|
143
|
+
? this.negotiatedProtocols[0]
|
|
144
|
+
: this.negotiatedProtocols;
|
|
141
145
|
this.log.debug(`Using WebSocket protocols arg: ${JSON.stringify(protocols)}`);
|
|
146
|
+
const headers = this.capabilityToken
|
|
147
|
+
? {
|
|
148
|
+
'X-MCP-Capability': this.capabilityToken,
|
|
149
|
+
'X-MCP-Capability-Token': this.capabilityToken
|
|
150
|
+
}
|
|
151
|
+
: undefined;
|
|
142
152
|
const socket = new WebSocket(url, protocols, {
|
|
143
|
-
headers
|
|
153
|
+
headers,
|
|
144
154
|
perMessageDeflate: false
|
|
145
155
|
});
|
|
146
156
|
this.handleClientConnection(socket);
|
|
@@ -174,9 +184,54 @@ export class AutomationBridge extends EventEmitter {
|
|
|
174
184
|
port: this.clientPort,
|
|
175
185
|
protocol: socket.protocol || null
|
|
176
186
|
});
|
|
187
|
+
const getRawDataByteLength = (data) => {
|
|
188
|
+
if (typeof data === 'string') {
|
|
189
|
+
return Buffer.byteLength(data, 'utf8');
|
|
190
|
+
}
|
|
191
|
+
if (Buffer.isBuffer(data)) {
|
|
192
|
+
return data.length;
|
|
193
|
+
}
|
|
194
|
+
if (Array.isArray(data)) {
|
|
195
|
+
return data.reduce((total, item) => total + (Buffer.isBuffer(item) ? item.length : 0), 0);
|
|
196
|
+
}
|
|
197
|
+
if (data instanceof ArrayBuffer) {
|
|
198
|
+
return data.byteLength;
|
|
199
|
+
}
|
|
200
|
+
if (ArrayBuffer.isView(data)) {
|
|
201
|
+
return data.byteLength;
|
|
202
|
+
}
|
|
203
|
+
return 0;
|
|
204
|
+
};
|
|
205
|
+
const rawDataToUtf8String = (data, byteLengthHint) => {
|
|
206
|
+
if (typeof data === 'string') {
|
|
207
|
+
return data;
|
|
208
|
+
}
|
|
209
|
+
if (Buffer.isBuffer(data)) {
|
|
210
|
+
return data.toString('utf8');
|
|
211
|
+
}
|
|
212
|
+
if (Array.isArray(data)) {
|
|
213
|
+
const buffers = data.filter((item) => Buffer.isBuffer(item));
|
|
214
|
+
const totalLength = typeof byteLengthHint === 'number'
|
|
215
|
+
? byteLengthHint
|
|
216
|
+
: buffers.reduce((total, item) => total + item.length, 0);
|
|
217
|
+
return Buffer.concat(buffers, totalLength).toString('utf8');
|
|
218
|
+
}
|
|
219
|
+
if (data instanceof ArrayBuffer) {
|
|
220
|
+
return Buffer.from(data).toString('utf8');
|
|
221
|
+
}
|
|
222
|
+
if (ArrayBuffer.isView(data)) {
|
|
223
|
+
return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString('utf8');
|
|
224
|
+
}
|
|
225
|
+
return '';
|
|
226
|
+
};
|
|
177
227
|
socket.on('message', (data) => {
|
|
178
228
|
try {
|
|
179
|
-
const
|
|
229
|
+
const byteLength = getRawDataByteLength(data);
|
|
230
|
+
if (byteLength > MAX_WS_MESSAGE_SIZE_BYTES) {
|
|
231
|
+
this.log.error(`Received oversized message (${byteLength} bytes, max: ${MAX_WS_MESSAGE_SIZE_BYTES}). Dropping.`);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
const text = rawDataToUtf8String(data, byteLength);
|
|
180
235
|
this.log.debug(`[AutomationBridge Client] Received message: ${text.substring(0, 1000)}`);
|
|
181
236
|
const parsed = JSON.parse(text);
|
|
182
237
|
this.connectionManager.updateLastMessageTime();
|
|
@@ -350,6 +405,9 @@ export class AutomationBridge extends EventEmitter {
|
|
|
350
405
|
throw new Error('Automation bridge not connected');
|
|
351
406
|
}
|
|
352
407
|
if (this.requestTracker.getPendingCount() >= this.requestTracker.getMaxPendingRequests()) {
|
|
408
|
+
if (this.queuedRequestItems.length >= this.maxQueuedRequests) {
|
|
409
|
+
throw new Error(`Automation bridge request queue is full (max: ${this.maxQueuedRequests}). Please retry later.`);
|
|
410
|
+
}
|
|
353
411
|
return new Promise((resolve, reject) => {
|
|
354
412
|
this.queuedRequestItems.push({
|
|
355
413
|
resolve,
|
package/dist/constants.d.ts
CHANGED
|
@@ -5,6 +5,7 @@ export declare const DEFAULT_NEGOTIATED_PROTOCOLS: string[];
|
|
|
5
5
|
export declare const DEFAULT_HEARTBEAT_INTERVAL_MS = 10000;
|
|
6
6
|
export declare const DEFAULT_HANDSHAKE_TIMEOUT_MS = 5000;
|
|
7
7
|
export declare const DEFAULT_MAX_PENDING_REQUESTS = 25;
|
|
8
|
+
export declare const DEFAULT_MAX_QUEUED_REQUESTS = 100;
|
|
8
9
|
export declare const DEFAULT_TIME_OF_DAY = 9;
|
|
9
10
|
export declare const DEFAULT_SUN_INTENSITY = 10000;
|
|
10
11
|
export declare const DEFAULT_SKYLIGHT_INTENSITY = 1;
|
|
@@ -16,4 +17,5 @@ export declare const LONG_RUNNING_OP_TIMEOUT_MS = 300000;
|
|
|
16
17
|
export declare const CONSOLE_COMMAND_TIMEOUT_MS = 30000;
|
|
17
18
|
export declare const ENGINE_QUERY_TIMEOUT_MS = 15000;
|
|
18
19
|
export declare const CONNECTION_TIMEOUT_MS = 15000;
|
|
20
|
+
export declare const MAX_WS_MESSAGE_SIZE_BYTES: number;
|
|
19
21
|
//# sourceMappingURL=constants.d.ts.map
|
package/dist/constants.js
CHANGED
|
@@ -5,6 +5,7 @@ export const DEFAULT_NEGOTIATED_PROTOCOLS = ['mcp-automation'];
|
|
|
5
5
|
export const DEFAULT_HEARTBEAT_INTERVAL_MS = 10000;
|
|
6
6
|
export const DEFAULT_HANDSHAKE_TIMEOUT_MS = 5000;
|
|
7
7
|
export const DEFAULT_MAX_PENDING_REQUESTS = 25;
|
|
8
|
+
export const DEFAULT_MAX_QUEUED_REQUESTS = 100;
|
|
8
9
|
export const DEFAULT_TIME_OF_DAY = 9;
|
|
9
10
|
export const DEFAULT_SUN_INTENSITY = 10000;
|
|
10
11
|
export const DEFAULT_SKYLIGHT_INTENSITY = 1;
|
|
@@ -16,4 +17,5 @@ export const LONG_RUNNING_OP_TIMEOUT_MS = 300000;
|
|
|
16
17
|
export const CONSOLE_COMMAND_TIMEOUT_MS = 30000;
|
|
17
18
|
export const ENGINE_QUERY_TIMEOUT_MS = 15000;
|
|
18
19
|
export const CONNECTION_TIMEOUT_MS = 15000;
|
|
20
|
+
export const MAX_WS_MESSAGE_SIZE_BYTES = 5 * 1024 * 1024;
|
|
19
21
|
//# sourceMappingURL=constants.js.map
|
package/dist/graphql/server.d.ts
CHANGED
|
@@ -19,7 +19,6 @@ export declare class GraphQLServer {
|
|
|
19
19
|
constructor(bridge: UnrealBridge, automationBridge: AutomationBridge, config?: GraphQLServerConfig);
|
|
20
20
|
start(): Promise<void>;
|
|
21
21
|
stop(): Promise<void>;
|
|
22
|
-
private setupShutdown;
|
|
23
22
|
getConfig(): Required<GraphQLServerConfig>;
|
|
24
23
|
isRunning(): boolean;
|
|
25
24
|
}
|
package/dist/graphql/server.js
CHANGED
|
@@ -28,6 +28,21 @@ export class GraphQLServer {
|
|
|
28
28
|
this.log.info('GraphQL server is disabled');
|
|
29
29
|
return;
|
|
30
30
|
}
|
|
31
|
+
const isLoopback = this.config.host === '127.0.0.1' ||
|
|
32
|
+
this.config.host === '::1' ||
|
|
33
|
+
this.config.host.toLowerCase() === 'localhost';
|
|
34
|
+
const allowRemote = process.env.GRAPHQL_ALLOW_REMOTE === 'true';
|
|
35
|
+
if (!isLoopback && !allowRemote) {
|
|
36
|
+
this.log.warn(`GraphQL server is configured to bind to non-loopback host '${this.config.host}'. GraphQL is for local debugging only. ` +
|
|
37
|
+
'To allow remote binding, set GRAPHQL_ALLOW_REMOTE=true. Aborting start.');
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (!isLoopback && allowRemote) {
|
|
41
|
+
if (this.config.cors.origin === '*') {
|
|
42
|
+
this.log.warn("GraphQL server is binding to a remote host with permissive CORS origin '*'. " +
|
|
43
|
+
'Set GRAPHQL_CORS_ORIGIN to specific origins for production. Using permissive CORS for now.');
|
|
44
|
+
}
|
|
45
|
+
}
|
|
31
46
|
try {
|
|
32
47
|
const schema = createGraphQLSchema(this.bridge, this.automationBridge);
|
|
33
48
|
const yoga = createYoga({
|
|
@@ -67,7 +82,6 @@ export class GraphQLServer {
|
|
|
67
82
|
resolve();
|
|
68
83
|
});
|
|
69
84
|
});
|
|
70
|
-
this.setupShutdown();
|
|
71
85
|
}
|
|
72
86
|
catch (error) {
|
|
73
87
|
this.log.error('Failed to start GraphQL server:', error);
|
|
@@ -92,21 +106,6 @@ export class GraphQLServer {
|
|
|
92
106
|
});
|
|
93
107
|
});
|
|
94
108
|
}
|
|
95
|
-
setupShutdown() {
|
|
96
|
-
const gracefulShutdown = async (signal) => {
|
|
97
|
-
this.log.info(`Received ${signal}, shutting down GraphQL server...`);
|
|
98
|
-
try {
|
|
99
|
-
await this.stop();
|
|
100
|
-
process.exit(0);
|
|
101
|
-
}
|
|
102
|
-
catch (error) {
|
|
103
|
-
this.log.error('Error during GraphQL server shutdown:', error);
|
|
104
|
-
process.exit(1);
|
|
105
|
-
}
|
|
106
|
-
};
|
|
107
|
-
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
|
108
|
-
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
|
109
|
-
}
|
|
110
109
|
getConfig() {
|
|
111
110
|
return this.config;
|
|
112
111
|
}
|
package/dist/index.js
CHANGED
|
@@ -29,7 +29,7 @@ const DEFAULT_SERVER_NAME = typeof packageInfo.name === 'string' && packageInfo.
|
|
|
29
29
|
: 'unreal-engine-mcp';
|
|
30
30
|
const DEFAULT_SERVER_VERSION = typeof packageInfo.version === 'string' && packageInfo.version.trim().length > 0
|
|
31
31
|
? packageInfo.version
|
|
32
|
-
: '0.5.
|
|
32
|
+
: '0.5.4';
|
|
33
33
|
function routeStdoutLogsToStderr() {
|
|
34
34
|
if (!config.MCP_ROUTE_STDOUT_LOGS) {
|
|
35
35
|
return;
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import http from 'http';
|
|
2
2
|
import { wasmIntegration } from '../wasm/index.js';
|
|
3
|
-
import { DEFAULT_AUTOMATION_HOST } from '../constants.js';
|
|
4
3
|
function formatPrometheusMetrics(options) {
|
|
5
4
|
const { healthMonitor, automationBridge } = options;
|
|
6
5
|
const m = healthMonitor.metrics;
|
|
@@ -61,6 +60,7 @@ export function startMetricsServer(options) {
|
|
|
61
60
|
logger.debug('Metrics server disabled (set MCP_METRICS_PORT to enable Prometheus /metrics endpoint).');
|
|
62
61
|
return null;
|
|
63
62
|
}
|
|
63
|
+
const host = process.env.MCP_METRICS_HOST || '127.0.0.1';
|
|
64
64
|
const RATE_LIMIT_WINDOW_MS = 60000;
|
|
65
65
|
const RATE_LIMIT_MAX_REQUESTS = 60;
|
|
66
66
|
const requestCounts = new Map();
|
|
@@ -114,8 +114,8 @@ export function startMetricsServer(options) {
|
|
|
114
114
|
res.end('Internal Server Error');
|
|
115
115
|
}
|
|
116
116
|
});
|
|
117
|
-
server.listen(port, () => {
|
|
118
|
-
logger.info(`Prometheus metrics server listening on http://${
|
|
117
|
+
server.listen(port, host, () => {
|
|
118
|
+
logger.info(`Prometheus metrics server listening on http://${host}:${port}/metrics`);
|
|
119
119
|
});
|
|
120
120
|
server.on('error', (err) => {
|
|
121
121
|
logger.warn('Metrics server error', err);
|
|
@@ -3,6 +3,55 @@ import { executeAutomationRequest } from './common-handlers.js';
|
|
|
3
3
|
import { spawn } from 'child_process';
|
|
4
4
|
import path from 'path';
|
|
5
5
|
import fs from 'fs';
|
|
6
|
+
function validateUbtArgumentsString(extraArgs) {
|
|
7
|
+
if (!extraArgs || typeof extraArgs !== 'string') {
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
const forbiddenChars = ['\n', '\r', ';', '|', '`', '&&', '||', '>', '<'];
|
|
11
|
+
for (const char of forbiddenChars) {
|
|
12
|
+
if (extraArgs.includes(char)) {
|
|
13
|
+
throw new Error(`UBT arguments contain forbidden character(s) and are blocked for safety. Blocked: ${JSON.stringify(char)}.`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function tokenizeArgs(extraArgs) {
|
|
18
|
+
if (!extraArgs) {
|
|
19
|
+
return [];
|
|
20
|
+
}
|
|
21
|
+
const args = [];
|
|
22
|
+
let current = '';
|
|
23
|
+
let inQuotes = false;
|
|
24
|
+
let escapeNext = false;
|
|
25
|
+
for (let i = 0; i < extraArgs.length; i++) {
|
|
26
|
+
const ch = extraArgs[i];
|
|
27
|
+
if (escapeNext) {
|
|
28
|
+
current += ch;
|
|
29
|
+
escapeNext = false;
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (ch === '\\') {
|
|
33
|
+
escapeNext = true;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (ch === '"') {
|
|
37
|
+
inQuotes = !inQuotes;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (!inQuotes && /\s/.test(ch)) {
|
|
41
|
+
if (current.length > 0) {
|
|
42
|
+
args.push(current);
|
|
43
|
+
current = '';
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
current += ch;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (current.length > 0) {
|
|
51
|
+
args.push(current);
|
|
52
|
+
}
|
|
53
|
+
return args;
|
|
54
|
+
}
|
|
6
55
|
export async function handlePipelineTools(action, args, tools) {
|
|
7
56
|
switch (action) {
|
|
8
57
|
case 'run_ubt': {
|
|
@@ -13,6 +62,7 @@ export async function handlePipelineTools(action, args, tools) {
|
|
|
13
62
|
if (!target) {
|
|
14
63
|
throw new Error('Target is required for run_ubt');
|
|
15
64
|
}
|
|
65
|
+
validateUbtArgumentsString(extraArgs);
|
|
16
66
|
let ubtPath = 'UnrealBuildTool';
|
|
17
67
|
const enginePath = process.env.UE_ENGINE_PATH || process.env.UNREAL_ENGINE_PATH;
|
|
18
68
|
if (enginePath) {
|
|
@@ -41,15 +91,17 @@ export async function handlePipelineTools(action, args, tools) {
|
|
|
41
91
|
throw new Error(`Could not read project directory: ${projectPath}`);
|
|
42
92
|
}
|
|
43
93
|
}
|
|
94
|
+
const projectArg = `-Project="${uprojectFile}"`;
|
|
95
|
+
const extraTokens = tokenizeArgs(extraArgs);
|
|
44
96
|
const cmdArgs = [
|
|
45
97
|
target,
|
|
46
98
|
platform,
|
|
47
99
|
configuration,
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
]
|
|
100
|
+
projectArg,
|
|
101
|
+
...extraTokens
|
|
102
|
+
];
|
|
51
103
|
return new Promise((resolve) => {
|
|
52
|
-
const child = spawn(ubtPath, cmdArgs, { shell:
|
|
104
|
+
const child = spawn(ubtPath, cmdArgs, { shell: false });
|
|
53
105
|
const MAX_OUTPUT_SIZE = 20 * 1024;
|
|
54
106
|
let stdout = '';
|
|
55
107
|
let stderr = '';
|
|
@@ -73,12 +125,13 @@ export async function handlePipelineTools(action, args, tools) {
|
|
|
73
125
|
const truncatedNote = (stdout.length >= MAX_OUTPUT_SIZE || stderr.length >= MAX_OUTPUT_SIZE)
|
|
74
126
|
? '\n[Output truncated for response payload]'
|
|
75
127
|
: '';
|
|
128
|
+
const quotedArgs = cmdArgs.map(arg => arg.includes(' ') ? `"${arg}"` : arg);
|
|
76
129
|
if (code === 0) {
|
|
77
130
|
resolve({
|
|
78
131
|
success: true,
|
|
79
132
|
message: 'UnrealBuildTool finished successfully',
|
|
80
133
|
output: stdout + truncatedNote,
|
|
81
|
-
command: `${ubtPath} ${
|
|
134
|
+
command: `${ubtPath} ${quotedArgs.join(' ')}`
|
|
82
135
|
});
|
|
83
136
|
}
|
|
84
137
|
else {
|
|
@@ -88,16 +141,17 @@ export async function handlePipelineTools(action, args, tools) {
|
|
|
88
141
|
message: `UnrealBuildTool failed with code ${code}`,
|
|
89
142
|
output: stdout + truncatedNote,
|
|
90
143
|
errorOutput: stderr + truncatedNote,
|
|
91
|
-
command: `${ubtPath} ${
|
|
144
|
+
command: `${ubtPath} ${quotedArgs.join(' ')}`
|
|
92
145
|
});
|
|
93
146
|
}
|
|
94
147
|
});
|
|
95
148
|
child.on('error', (err) => {
|
|
149
|
+
const quotedArgs = cmdArgs.map(arg => arg.includes(' ') ? `"${arg}"` : arg);
|
|
96
150
|
resolve({
|
|
97
151
|
success: false,
|
|
98
152
|
error: 'SPAWN_FAILED',
|
|
99
153
|
message: `Failed to spawn UnrealBuildTool: ${err.message}`,
|
|
100
|
-
command: `${ubtPath} ${
|
|
154
|
+
command: `${ubtPath} ${quotedArgs.join(' ')}`
|
|
101
155
|
});
|
|
102
156
|
});
|
|
103
157
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "unreal-engine-mcp-server",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.4",
|
|
4
4
|
"mcpName": "io.github.ChiR24/unreal-engine-mcp",
|
|
5
5
|
"description": "A comprehensive Model Context Protocol (MCP) server that enables AI assistants to control Unreal Engine via native automation bridge. Built with TypeScript and designed for game development automation.",
|
|
6
6
|
"type": "module",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
#include "McpAutomationBridgeGlobals.h"
|
|
2
2
|
#include "McpAutomationBridgeHelpers.h"
|
|
3
3
|
#include "McpAutomationBridgeSubsystem.h"
|
|
4
4
|
|
|
@@ -1024,6 +1024,30 @@ bool UMcpAutomationBridgeSubsystem::HandleConsoleCommandAction(
|
|
|
1024
1024
|
return true;
|
|
1025
1025
|
}
|
|
1026
1026
|
|
|
1027
|
+
// 4. Block line breaks
|
|
1028
|
+
if (LowerCommand.Contains(TEXT("\n")) || LowerCommand.Contains(TEXT("\r"))) {
|
|
1029
|
+
SendAutomationResponse(RequestingSocket, RequestId, false,
|
|
1030
|
+
TEXT("Multi-line commands are blocked for safety"),
|
|
1031
|
+
nullptr, TEXT("COMMAND_BLOCKED"));
|
|
1032
|
+
return true;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// 5. Block semicolon and pipe
|
|
1036
|
+
if (LowerCommand.Contains(TEXT(";")) || LowerCommand.Contains(TEXT("|"))) {
|
|
1037
|
+
SendAutomationResponse(RequestingSocket, RequestId, false,
|
|
1038
|
+
TEXT("Command chaining with semicolon or pipe is blocked for safety"),
|
|
1039
|
+
nullptr, TEXT("COMMAND_BLOCKED"));
|
|
1040
|
+
return true;
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
// 6. Block backticks
|
|
1044
|
+
if (LowerCommand.Contains(TEXT("`"))) {
|
|
1045
|
+
SendAutomationResponse(RequestingSocket, RequestId, false,
|
|
1046
|
+
TEXT("Commands containing backticks are blocked for safety"),
|
|
1047
|
+
nullptr, TEXT("COMMAND_BLOCKED"));
|
|
1048
|
+
return true;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1027
1051
|
// Execute the command
|
|
1028
1052
|
try {
|
|
1029
1053
|
UWorld *TargetWorld = nullptr;
|
package/plugins/McpAutomationBridge/Source/McpAutomationBridge/Private/McpBridgeWebSocket.cpp
CHANGED
|
@@ -446,6 +446,8 @@ uint32 FMcpBridgeWebSocket::RunServer() {
|
|
|
446
446
|
TSharedRef<FInternetAddr> ListenAddr = SocketSubsystem->CreateInternetAddr();
|
|
447
447
|
|
|
448
448
|
bool bResolvedHost = false;
|
|
449
|
+
bool bExplicitBindAll = false;
|
|
450
|
+
|
|
449
451
|
if (!ListenHost.IsEmpty()) {
|
|
450
452
|
FString HostToBind = ListenHost;
|
|
451
453
|
if (HostToBind.Equals(TEXT("localhost"), ESearchCase::IgnoreCase)) {
|
|
@@ -457,10 +459,23 @@ uint32 FMcpBridgeWebSocket::RunServer() {
|
|
|
457
459
|
if (bIsValidIp) {
|
|
458
460
|
bResolvedHost = true;
|
|
459
461
|
}
|
|
462
|
+
|
|
463
|
+
bExplicitBindAll = HostToBind.Equals(TEXT("0.0.0.0"), ESearchCase::IgnoreCase) ||
|
|
464
|
+
HostToBind.Equals(TEXT("::"), ESearchCase::IgnoreCase);
|
|
460
465
|
}
|
|
461
466
|
|
|
462
467
|
if (!bResolvedHost) {
|
|
463
|
-
|
|
468
|
+
if (!bExplicitBindAll) {
|
|
469
|
+
UE_LOG(LogMcpAutomationBridgeSubsystem, Warning,
|
|
470
|
+
TEXT("Invalid ListenHost '%s'. Falling back to 127.0.0.1 for safety. To bind all interfaces, explicitly set ListenHost=0.0.0.0."),
|
|
471
|
+
*ListenHost);
|
|
472
|
+
|
|
473
|
+
bool bFallbackIsValidIp = false;
|
|
474
|
+
ListenAddr->SetIp(TEXT("127.0.0.1"), bFallbackIsValidIp);
|
|
475
|
+
bResolvedHost = bFallbackIsValidIp;
|
|
476
|
+
} else {
|
|
477
|
+
ListenAddr->SetAnyAddress();
|
|
478
|
+
}
|
|
464
479
|
}
|
|
465
480
|
|
|
466
481
|
ListenAddr->SetPort(Port);
|
package/server.json
CHANGED
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
3
|
"name": "io.github.ChiR24/unreal-engine-mcp",
|
|
4
4
|
"description": "MCP server for Unreal Engine 5 with 17 tools for game development automation.",
|
|
5
|
-
"version": "0.5.
|
|
5
|
+
"version": "0.5.4",
|
|
6
6
|
"packages": [
|
|
7
7
|
{
|
|
8
8
|
"registryType": "npm",
|
|
9
9
|
"registryBaseUrl": "https://registry.npmjs.org",
|
|
10
10
|
"identifier": "unreal-engine-mcp-server",
|
|
11
|
-
"version": "0.5.
|
|
11
|
+
"version": "0.5.4",
|
|
12
12
|
"transport": {
|
|
13
13
|
"type": "stdio"
|
|
14
14
|
},
|