godot-mcp-runtime 2.3.0 → 3.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/README.md +46 -132
- package/dist/dispatch.d.ts +1 -11
- package/dist/dispatch.d.ts.map +1 -1
- package/dist/dispatch.js +32 -33
- package/dist/dispatch.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -10
- package/dist/index.js.map +1 -1
- package/dist/scripts/godot_operations.gd +268 -382
- package/dist/scripts/mcp_bridge.gd +206 -44
- package/dist/tools/autoload-tools.d.ts +51 -0
- package/dist/tools/autoload-tools.d.ts.map +1 -0
- package/dist/tools/autoload-tools.js +191 -0
- package/dist/tools/autoload-tools.js.map +1 -0
- package/dist/tools/node-tools.d.ts +9 -78
- package/dist/tools/node-tools.d.ts.map +1 -1
- package/dist/tools/node-tools.js +188 -312
- package/dist/tools/node-tools.js.map +1 -1
- package/dist/tools/project-tools.d.ts +0 -168
- package/dist/tools/project-tools.d.ts.map +1 -1
- package/dist/tools/project-tools.js +191 -1240
- package/dist/tools/project-tools.js.map +1 -1
- package/dist/tools/runtime-tools.d.ts +108 -0
- package/dist/tools/runtime-tools.d.ts.map +1 -0
- package/dist/tools/runtime-tools.js +994 -0
- package/dist/tools/runtime-tools.js.map +1 -0
- package/dist/tools/scene-tools.d.ts +6 -48
- package/dist/tools/scene-tools.d.ts.map +1 -1
- package/dist/tools/scene-tools.js +76 -212
- package/dist/tools/scene-tools.js.map +1 -1
- package/dist/tools/validate-tools.d.ts.map +1 -1
- package/dist/tools/validate-tools.js +115 -51
- package/dist/tools/validate-tools.js.map +1 -1
- package/dist/utils/autoload-ini.d.ts +38 -0
- package/dist/utils/autoload-ini.d.ts.map +1 -0
- package/dist/utils/autoload-ini.js +124 -0
- package/dist/utils/autoload-ini.js.map +1 -0
- package/dist/utils/bridge-manager.d.ts +46 -0
- package/dist/utils/bridge-manager.d.ts.map +1 -0
- package/dist/utils/bridge-manager.js +186 -0
- package/dist/utils/bridge-manager.js.map +1 -0
- package/dist/utils/bridge-protocol.d.ts +37 -0
- package/dist/utils/bridge-protocol.d.ts.map +1 -0
- package/dist/utils/bridge-protocol.js +78 -0
- package/dist/utils/bridge-protocol.js.map +1 -0
- package/dist/utils/godot-runner.d.ts +102 -16
- package/dist/utils/godot-runner.d.ts.map +1 -1
- package/dist/utils/godot-runner.js +497 -284
- package/dist/utils/godot-runner.js.map +1 -1
- package/dist/utils/handler-helpers.d.ts +34 -0
- package/dist/utils/handler-helpers.d.ts.map +1 -0
- package/dist/utils/handler-helpers.js +55 -0
- package/dist/utils/handler-helpers.js.map +1 -0
- package/dist/utils/logger.d.ts +4 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +11 -0
- package/dist/utils/logger.js.map +1 -0
- package/package.json +8 -4
|
@@ -1,20 +1,36 @@
|
|
|
1
1
|
import { fileURLToPath } from 'url';
|
|
2
|
-
import { join, dirname, normalize } from 'path';
|
|
3
|
-
import { existsSync
|
|
2
|
+
import { join, dirname, normalize, resolve, sep } from 'path';
|
|
3
|
+
import { existsSync } from 'fs';
|
|
4
4
|
import { spawn } from 'child_process';
|
|
5
|
-
import
|
|
5
|
+
import * as net from 'net';
|
|
6
6
|
import { randomBytes } from 'crypto';
|
|
7
|
+
import { BridgeManager } from './bridge-manager.js';
|
|
8
|
+
import { DEFAULT_BRIDGE_PORT, encodeFrame, findFreePort, parseFrames, FRAME_HEADER_BYTES, MAX_FRAME_BYTES, } from './bridge-protocol.js';
|
|
9
|
+
import { logDebug, logError, DEBUG_MODE } from './logger.js';
|
|
10
|
+
/**
|
|
11
|
+
* Thrown when the bridge socket closes (Godot exited, port closed, or peer
|
|
12
|
+
* dropped the connection mid-flight). Lets callers distinguish
|
|
13
|
+
* "session ended" from generic transport errors.
|
|
14
|
+
*/
|
|
15
|
+
export class BridgeDisconnectedError extends Error {
|
|
16
|
+
constructor(message) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.name = 'BridgeDisconnectedError';
|
|
19
|
+
}
|
|
20
|
+
}
|
|
7
21
|
// Derive __filename and __dirname in ESM
|
|
8
22
|
const __filename = fileURLToPath(import.meta.url);
|
|
9
23
|
const __dirname = dirname(__filename);
|
|
10
|
-
// Debug mode from environment
|
|
11
|
-
const DEBUG_MODE = process.env.DEBUG === 'true';
|
|
12
24
|
// Bridge readiness polling
|
|
13
|
-
const BRIDGE_WAIT_SPAWNED_TIMEOUT_MS = 8000;
|
|
25
|
+
export const BRIDGE_WAIT_SPAWNED_TIMEOUT_MS = 8000;
|
|
14
26
|
const BRIDGE_WAIT_SPAWNED_INTERVAL_MS = 300;
|
|
15
27
|
const BRIDGE_WAIT_ATTACHED_TIMEOUT_MS = 15000;
|
|
16
28
|
const BRIDGE_WAIT_ATTACHED_INTERVAL_MS = 500;
|
|
17
29
|
const BRIDGE_PING_TIMEOUT_MS = 1000;
|
|
30
|
+
const BRIDGE_SHUTDOWN_SPAWNED_TIMEOUT_MS = 500;
|
|
31
|
+
const BRIDGE_SHUTDOWN_ATTACHED_TIMEOUT_MS = 1500;
|
|
32
|
+
const BRIDGE_PROCESS_EXIT_TIMEOUT_MS = 2000;
|
|
33
|
+
const BRIDGE_RECONNECT_DELAY_MS = 1000;
|
|
18
34
|
/**
|
|
19
35
|
* Normalize a path for cross-platform comparison.
|
|
20
36
|
* Folds Windows backslashes to forward slashes and strips trailing slashes,
|
|
@@ -94,35 +110,50 @@ export function cleanOutput(output) {
|
|
|
94
110
|
});
|
|
95
111
|
return cleanedLines.join('\n');
|
|
96
112
|
}
|
|
113
|
+
export function cleanStdout(stdout) {
|
|
114
|
+
if (stdout.includes('{') || stdout.includes('[')) {
|
|
115
|
+
return extractJson(stdout);
|
|
116
|
+
}
|
|
117
|
+
return cleanOutput(stdout);
|
|
118
|
+
}
|
|
97
119
|
// Parameter mappings between snake_case and camelCase
|
|
120
|
+
// Add new entries whenever a tool surfaces a new compound parameter — the
|
|
121
|
+
// strict converter throws in test env on unmapped keys to catch oversights.
|
|
98
122
|
const parameterMappings = {
|
|
99
123
|
project_path: 'projectPath',
|
|
100
124
|
scene_path: 'scenePath',
|
|
101
125
|
root_node_type: 'rootNodeType',
|
|
102
126
|
parent_node_path: 'parentNodePath',
|
|
127
|
+
parent_path: 'parentPath',
|
|
103
128
|
node_type: 'nodeType',
|
|
104
129
|
node_name: 'nodeName',
|
|
105
130
|
texture_path: 'texturePath',
|
|
106
131
|
node_path: 'nodePath',
|
|
132
|
+
node_paths: 'nodePaths',
|
|
133
|
+
target_node_path: 'targetNodePath',
|
|
134
|
+
target_parent_path: 'targetParentPath',
|
|
135
|
+
new_name: 'newName',
|
|
107
136
|
output_path: 'outputPath',
|
|
108
137
|
mesh_item_names: 'meshItemNames',
|
|
109
138
|
new_path: 'newPath',
|
|
110
139
|
file_path: 'filePath',
|
|
111
140
|
script_path: 'scriptPath',
|
|
141
|
+
response_mode: 'responseMode',
|
|
142
|
+
preview_max_width: 'previewMaxWidth',
|
|
143
|
+
preview_max_height: 'previewMaxHeight',
|
|
144
|
+
bridge_port: 'bridgePort',
|
|
145
|
+
abort_on_error: 'abortOnError',
|
|
146
|
+
max_depth: 'maxDepth',
|
|
147
|
+
changed_only: 'changedOnly',
|
|
148
|
+
case_sensitive: 'caseSensitive',
|
|
149
|
+
file_types: 'fileTypes',
|
|
150
|
+
max_results: 'maxResults',
|
|
112
151
|
};
|
|
113
152
|
// Reverse mapping from camelCase to snake_case
|
|
114
153
|
const reverseParameterMappings = {};
|
|
115
154
|
for (const [snakeCase, camelCase] of Object.entries(parameterMappings)) {
|
|
116
155
|
reverseParameterMappings[camelCase] = snakeCase;
|
|
117
156
|
}
|
|
118
|
-
export function logDebug(message) {
|
|
119
|
-
if (DEBUG_MODE) {
|
|
120
|
-
console.error(`[DEBUG] ${message}`);
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
export function logError(message) {
|
|
124
|
-
console.error(`[SERVER] ${message}`);
|
|
125
|
-
}
|
|
126
157
|
export function normalizeParameters(params) {
|
|
127
158
|
if (!params || typeof params !== 'object') {
|
|
128
159
|
return params;
|
|
@@ -145,29 +176,116 @@ export function normalizeParameters(params) {
|
|
|
145
176
|
}
|
|
146
177
|
return result;
|
|
147
178
|
}
|
|
179
|
+
function convertCamelToSnakeValue(value) {
|
|
180
|
+
if (Array.isArray(value)) {
|
|
181
|
+
return value.map((entry) => convertCamelToSnakeValue(entry));
|
|
182
|
+
}
|
|
183
|
+
if (typeof value === 'object' && value !== null) {
|
|
184
|
+
return convertCamelToSnakeCase(value);
|
|
185
|
+
}
|
|
186
|
+
return value;
|
|
187
|
+
}
|
|
148
188
|
export function convertCamelToSnakeCase(params) {
|
|
149
189
|
const result = {};
|
|
190
|
+
const isTestEnv = process.env.NODE_ENV === 'test' || process.env.VITEST === 'true';
|
|
150
191
|
for (const key in params) {
|
|
151
192
|
if (Object.prototype.hasOwnProperty.call(params, key)) {
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
193
|
+
const mapped = reverseParameterMappings[key];
|
|
194
|
+
let snakeKey;
|
|
195
|
+
if (mapped) {
|
|
196
|
+
snakeKey = mapped;
|
|
197
|
+
}
|
|
198
|
+
else if (/[A-Z]/.test(key)) {
|
|
199
|
+
// Unmapped camelCase key — tolerated in production via regex fallback,
|
|
200
|
+
// but in tests we throw to catch missing entries in parameterMappings.
|
|
201
|
+
if (isTestEnv) {
|
|
202
|
+
throw new Error(`convertCamelToSnakeCase: unmapped camelCase key '${key}'. ` +
|
|
203
|
+
`Add it to parameterMappings in src/utils/godot-runner.ts so snake/camel conversion stays explicit.`);
|
|
204
|
+
}
|
|
205
|
+
snakeKey = key.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
|
|
157
206
|
}
|
|
158
207
|
else {
|
|
159
|
-
|
|
208
|
+
snakeKey = key;
|
|
160
209
|
}
|
|
210
|
+
result[snakeKey] = convertCamelToSnakeValue(params[key]);
|
|
161
211
|
}
|
|
162
212
|
}
|
|
163
213
|
return result;
|
|
164
214
|
}
|
|
215
|
+
/**
|
|
216
|
+
* Check whether a display server (X11 / Wayland) is available on the current
|
|
217
|
+
* platform. On macOS and Windows the display subsystem is always present;
|
|
218
|
+
* on Linux we probe the standard environment variables.
|
|
219
|
+
*/
|
|
220
|
+
export function checkDisplayAvailable() {
|
|
221
|
+
if (process.platform !== 'linux')
|
|
222
|
+
return true;
|
|
223
|
+
return !!(process.env.DISPLAY || process.env.WAYLAND_DISPLAY);
|
|
224
|
+
}
|
|
165
225
|
export function validatePath(path) {
|
|
166
226
|
if (!path || path.includes('..')) {
|
|
167
227
|
return false;
|
|
168
228
|
}
|
|
169
229
|
return true;
|
|
170
230
|
}
|
|
231
|
+
/**
|
|
232
|
+
* Stricter check for paths that must stay inside `projectPath`. Rejects `..`
|
|
233
|
+
* (via `validatePath`) and absolute paths that escape the project root.
|
|
234
|
+
* `path.join('/project', '/etc/passwd')` resolves to `/etc/passwd`, so the
|
|
235
|
+
* basic `..`-substring check alone permits absolute-path traversal.
|
|
236
|
+
*
|
|
237
|
+
* Tolerates a leading `res://` (Godot's project-root URI) by stripping it
|
|
238
|
+
* before resolving — autoload entries and resource paths use this prefix.
|
|
239
|
+
*/
|
|
240
|
+
export function validateSubPath(projectPath, userPath) {
|
|
241
|
+
if (!validatePath(userPath))
|
|
242
|
+
return false;
|
|
243
|
+
const stripped = userPath.startsWith('res://') ? userPath.slice('res://'.length) : userPath;
|
|
244
|
+
if (!stripped)
|
|
245
|
+
return false;
|
|
246
|
+
const projectRoot = resolve(projectPath);
|
|
247
|
+
const resolved = resolve(projectRoot, stripped);
|
|
248
|
+
const tail = projectRoot === sep ? sep : projectRoot + sep;
|
|
249
|
+
return resolved === projectRoot || resolved.startsWith(tail);
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Validate a Godot scene-tree path (NodePath). Scene-tree paths are a
|
|
253
|
+
* separate namespace from filesystem paths — they address nodes inside
|
|
254
|
+
* a scene, not files on disk, so the project-root containment check
|
|
255
|
+
* in `validateSubPath` does not apply.
|
|
256
|
+
*
|
|
257
|
+
* Rejects empty strings and `..` segments. Accepts both relative
|
|
258
|
+
* (`root/Player`) and absolute (`/root/Player`) Godot forms; the
|
|
259
|
+
* codebase convention is the relative form.
|
|
260
|
+
*/
|
|
261
|
+
export function validateNodePath(path) {
|
|
262
|
+
return typeof path === 'string' && path.length > 0 && !path.includes('..');
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* True when `child` resolves to `parent` or a path beneath it. Used by
|
|
266
|
+
* defense-in-depth checks on bridge-returned paths (e.g. screenshot files
|
|
267
|
+
* that must live under `.mcp/screenshots/`).
|
|
268
|
+
*/
|
|
269
|
+
export function isUnderDir(parent, child) {
|
|
270
|
+
const parentResolved = resolve(parent);
|
|
271
|
+
const childResolved = resolve(child);
|
|
272
|
+
return childResolved === parentResolved || childResolved.startsWith(parentResolved + sep);
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Return `error.message` when `error` is an `Error`, otherwise `'Unknown error'`.
|
|
276
|
+
* Centralizes the catch-block boilerplate so handlers can build error responses
|
|
277
|
+
* without repeating the `instanceof Error` ternary.
|
|
278
|
+
*/
|
|
279
|
+
export function getErrorMessage(error) {
|
|
280
|
+
return error instanceof Error ? error.message : 'Unknown error';
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Build the absolute path to a project's `project.godot` manifest. Use this
|
|
284
|
+
* instead of `join(dir, 'project.godot')` ad hoc.
|
|
285
|
+
*/
|
|
286
|
+
export function projectGodotPath(projectDir) {
|
|
287
|
+
return join(projectDir, 'project.godot');
|
|
288
|
+
}
|
|
171
289
|
/**
|
|
172
290
|
* Extract the first [ERROR] message from GDScript stderr output.
|
|
173
291
|
* Falls back to a generic message if no [ERROR] line is found.
|
|
@@ -206,7 +324,7 @@ export function validateProjectArgs(args) {
|
|
|
206
324
|
'Provide a valid path without ".." or other potentially unsafe characters',
|
|
207
325
|
]);
|
|
208
326
|
}
|
|
209
|
-
const projectFile =
|
|
327
|
+
const projectFile = projectGodotPath(args.projectPath);
|
|
210
328
|
if (!existsSync(projectFile)) {
|
|
211
329
|
return createErrorResponse(`Not a valid Godot project: ${args.projectPath}`, [
|
|
212
330
|
'Ensure the path points to a directory containing a project.godot file',
|
|
@@ -227,9 +345,9 @@ export function validateSceneArgs(args, opts) {
|
|
|
227
345
|
}
|
|
228
346
|
return { projectPath: projectResult.projectPath, scenePath: '' };
|
|
229
347
|
}
|
|
230
|
-
if (!
|
|
348
|
+
if (!validateSubPath(projectResult.projectPath, args.scenePath)) {
|
|
231
349
|
return createErrorResponse('Invalid scene path', [
|
|
232
|
-
'Provide a valid path without ".."
|
|
350
|
+
'Provide a valid relative path without ".." that stays inside the project directory',
|
|
233
351
|
]);
|
|
234
352
|
}
|
|
235
353
|
if (sceneRequired) {
|
|
@@ -243,20 +361,48 @@ export function validateSceneArgs(args, opts) {
|
|
|
243
361
|
}
|
|
244
362
|
return { projectPath: projectResult.projectPath, scenePath: args.scenePath };
|
|
245
363
|
}
|
|
364
|
+
/**
|
|
365
|
+
* Read the first `n` bytes from a chunk array without concatenating the
|
|
366
|
+
* entire array. If the first chunk already has enough bytes, returns a
|
|
367
|
+
* zero-copy subarray; otherwise copies just `n` bytes into a fresh buffer.
|
|
368
|
+
* Caller must ensure total length across chunks is >= n.
|
|
369
|
+
*/
|
|
370
|
+
function readBytesFromChunks(chunks, n) {
|
|
371
|
+
if (chunks[0].length >= n)
|
|
372
|
+
return chunks[0].subarray(0, n);
|
|
373
|
+
const result = Buffer.allocUnsafe(n);
|
|
374
|
+
let copied = 0;
|
|
375
|
+
for (const c of chunks) {
|
|
376
|
+
const take = Math.min(c.length, n - copied);
|
|
377
|
+
c.copy(result, copied, 0, take);
|
|
378
|
+
copied += take;
|
|
379
|
+
if (copied >= n)
|
|
380
|
+
break;
|
|
381
|
+
}
|
|
382
|
+
return result;
|
|
383
|
+
}
|
|
246
384
|
export class GodotRunner {
|
|
247
385
|
godotPath = null;
|
|
248
386
|
operationsScriptPath;
|
|
249
|
-
|
|
387
|
+
bridge;
|
|
250
388
|
validatedPaths = new Map();
|
|
251
|
-
|
|
252
|
-
strictPathValidation;
|
|
389
|
+
cachedVersion = null;
|
|
253
390
|
activeProcess = null;
|
|
254
391
|
activeProjectPath = null;
|
|
255
392
|
activeSessionMode = null;
|
|
393
|
+
activeBridgePort = null;
|
|
394
|
+
socket = null;
|
|
395
|
+
// Receive buffer kept as an array of chunks until at least one complete frame
|
|
396
|
+
// is available. Avoids re-copying accumulated bytes on every TCP data event
|
|
397
|
+
// (the old `Buffer.concat([rxBuffer, chunk])` pattern was O(n²) on large
|
|
398
|
+
// frames split across many chunks).
|
|
399
|
+
rxChunks = [];
|
|
400
|
+
rxTotal = 0;
|
|
401
|
+
inFlight = null;
|
|
256
402
|
constructor(config) {
|
|
257
|
-
this.strictPathValidation = config?.strictPathValidation ?? false;
|
|
258
403
|
this.operationsScriptPath = join(__dirname, '..', 'scripts', 'godot_operations.gd');
|
|
259
|
-
|
|
404
|
+
const bridgeScriptPath = join(__dirname, '..', 'scripts', 'mcp_bridge.gd');
|
|
405
|
+
this.bridge = new BridgeManager(bridgeScriptPath);
|
|
260
406
|
logDebug(`Operations script path: ${this.operationsScriptPath}`);
|
|
261
407
|
if (config?.godotPath) {
|
|
262
408
|
const normalizedPath = normalize(config.godotPath);
|
|
@@ -361,36 +507,42 @@ export class GodotRunner {
|
|
|
361
507
|
else if (osPlatform === 'linux') {
|
|
362
508
|
possiblePaths.push('/usr/bin/godot', '/usr/local/bin/godot', '/snap/bin/godot', `${process.env.HOME}/.local/bin/godot`);
|
|
363
509
|
}
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
510
|
+
const normalizedCandidates = possiblePaths.map((p) => normalize(p));
|
|
511
|
+
const probeResults = await Promise.all(normalizedCandidates.map(async (p) => ({ path: p, valid: await this.isValidGodotPath(p) })));
|
|
512
|
+
const winner = probeResults.find((r) => r.valid);
|
|
513
|
+
if (winner) {
|
|
514
|
+
this.godotPath = winner.path;
|
|
515
|
+
logDebug(`Found Godot at: ${winner.path}`);
|
|
516
|
+
return;
|
|
371
517
|
}
|
|
372
518
|
logDebug(`Warning: Could not find Godot in common locations for ${osPlatform}`);
|
|
373
519
|
logError(`Could not find Godot in common locations for ${osPlatform}`);
|
|
374
|
-
if (
|
|
375
|
-
|
|
520
|
+
if (osPlatform === 'win32') {
|
|
521
|
+
this.godotPath = normalize('C:\\Program Files\\Godot\\Godot.exe');
|
|
522
|
+
}
|
|
523
|
+
else if (osPlatform === 'darwin') {
|
|
524
|
+
this.godotPath = normalize('/Applications/Godot.app/Contents/MacOS/Godot');
|
|
376
525
|
}
|
|
377
526
|
else {
|
|
378
|
-
|
|
379
|
-
this.godotPath = normalize('C:\\Program Files\\Godot\\Godot.exe');
|
|
380
|
-
}
|
|
381
|
-
else if (osPlatform === 'darwin') {
|
|
382
|
-
this.godotPath = normalize('/Applications/Godot.app/Contents/MacOS/Godot');
|
|
383
|
-
}
|
|
384
|
-
else {
|
|
385
|
-
this.godotPath = normalize('/usr/bin/godot');
|
|
386
|
-
}
|
|
387
|
-
logDebug(`Using default path: ${this.godotPath}, but this may not work.`);
|
|
527
|
+
this.godotPath = normalize('/usr/bin/godot');
|
|
388
528
|
}
|
|
529
|
+
logDebug(`Using default path: ${this.godotPath}, but this may not work.`);
|
|
389
530
|
}
|
|
390
531
|
getGodotPath() {
|
|
391
532
|
return this.godotPath;
|
|
392
533
|
}
|
|
534
|
+
/**
|
|
535
|
+
* Read the port currently baked into the project's bridge script. Returns
|
|
536
|
+
* null if the file is missing or malformed. Thin pass-through to
|
|
537
|
+
* BridgeManager — used by bridge-wait-timeout race detection.
|
|
538
|
+
*/
|
|
539
|
+
readBakedBridgePort(projectPath) {
|
|
540
|
+
return this.bridge.readBakedPort(projectPath);
|
|
541
|
+
}
|
|
393
542
|
async getVersion() {
|
|
543
|
+
if (this.cachedVersion !== null) {
|
|
544
|
+
return this.cachedVersion;
|
|
545
|
+
}
|
|
394
546
|
if (!this.godotPath) {
|
|
395
547
|
await this.detectGodotPath();
|
|
396
548
|
if (!this.godotPath) {
|
|
@@ -398,21 +550,13 @@ export class GodotRunner {
|
|
|
398
550
|
}
|
|
399
551
|
}
|
|
400
552
|
const { stdout } = await this.spawnAsync(this.godotPath, ['--version']);
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
isGodot44OrLater(version) {
|
|
404
|
-
const match = version.match(/^(\d+)\.(\d+)/);
|
|
405
|
-
if (match) {
|
|
406
|
-
const major = parseInt(match[1], 10);
|
|
407
|
-
const minor = parseInt(match[2], 10);
|
|
408
|
-
return major > 4 || (major === 4 && minor >= 4);
|
|
409
|
-
}
|
|
410
|
-
return false;
|
|
553
|
+
this.cachedVersion = stdout.trim();
|
|
554
|
+
return this.cachedVersion;
|
|
411
555
|
}
|
|
412
556
|
async executeOperation(operation, params, projectPath, timeoutMs = 30000) {
|
|
413
557
|
logDebug(`Executing operation: ${operation} in project: ${projectPath}`);
|
|
414
558
|
logDebug(`Original operation params: ${JSON.stringify(params)}`);
|
|
415
|
-
this.
|
|
559
|
+
this.bridge.repairOrphaned(projectPath);
|
|
416
560
|
const snakeCaseParams = convertCamelToSnakeCase(params);
|
|
417
561
|
logDebug(`Converted snake_case params: ${JSON.stringify(snakeCaseParams)}`);
|
|
418
562
|
if (!this.godotPath) {
|
|
@@ -433,12 +577,6 @@ export class GodotRunner {
|
|
|
433
577
|
...(DEBUG_MODE ? ['--debug-godot'] : []),
|
|
434
578
|
];
|
|
435
579
|
logDebug(`Command: ${this.godotPath} ${args.join(' ')}`);
|
|
436
|
-
function cleanStdout(stdout) {
|
|
437
|
-
if (stdout.includes('{') || stdout.includes('[')) {
|
|
438
|
-
return extractJson(stdout);
|
|
439
|
-
}
|
|
440
|
-
return cleanOutput(stdout);
|
|
441
|
-
}
|
|
442
580
|
let stdout = '';
|
|
443
581
|
let stderr = '';
|
|
444
582
|
try {
|
|
@@ -470,24 +608,32 @@ export class GodotRunner {
|
|
|
470
608
|
}
|
|
471
609
|
return spawn(this.godotPath, ['-e', '--path', projectPath], { stdio: 'pipe' });
|
|
472
610
|
}
|
|
473
|
-
runProject(projectPath, scene, background = false) {
|
|
611
|
+
async runProject(projectPath, scene, background = false, bridgePort) {
|
|
474
612
|
if (!this.godotPath) {
|
|
475
613
|
throw new Error('Godot path not set. Call detectGodotPath first.');
|
|
476
614
|
}
|
|
477
615
|
if (this.activeSessionMode === 'spawned' && this.activeProcess) {
|
|
478
616
|
logDebug('Killing existing Godot process before starting a new one');
|
|
617
|
+
this.closeConnection();
|
|
479
618
|
this.activeProcess.process.kill();
|
|
480
619
|
if (this.activeProjectPath && this.activeProjectPath !== projectPath) {
|
|
481
|
-
this.
|
|
620
|
+
this.bridge.cleanup(this.activeProjectPath);
|
|
482
621
|
}
|
|
483
622
|
}
|
|
484
623
|
else if (this.activeSessionMode === 'attached' &&
|
|
485
624
|
this.activeProjectPath &&
|
|
486
625
|
this.activeProjectPath !== projectPath) {
|
|
487
|
-
this.
|
|
626
|
+
this.closeConnection();
|
|
627
|
+
this.bridge.cleanup(this.activeProjectPath);
|
|
628
|
+
}
|
|
629
|
+
if (!checkDisplayAvailable()) {
|
|
630
|
+
throw new Error('No display server available (DISPLAY and WAYLAND_DISPLAY are both unset). ' +
|
|
631
|
+
'Godot requires a display to run a project window.');
|
|
488
632
|
}
|
|
633
|
+
const port = bridgePort ?? (await findFreePort());
|
|
634
|
+
this.activeBridgePort = port;
|
|
489
635
|
try {
|
|
490
|
-
this.
|
|
636
|
+
this.bridge.inject(projectPath, port);
|
|
491
637
|
}
|
|
492
638
|
catch (err) {
|
|
493
639
|
logDebug(`Non-fatal: Failed to inject bridge autoload: ${err}`);
|
|
@@ -495,15 +641,19 @@ export class GodotRunner {
|
|
|
495
641
|
this.activeProjectPath = projectPath;
|
|
496
642
|
this.activeSessionMode = 'spawned';
|
|
497
643
|
const cmdArgs = ['--path', projectPath];
|
|
498
|
-
if (scene &&
|
|
644
|
+
if (scene && validateSubPath(projectPath, scene)) {
|
|
499
645
|
logDebug(`Adding scene parameter: ${scene}`);
|
|
500
646
|
cmdArgs.push(scene);
|
|
501
647
|
}
|
|
502
|
-
|
|
648
|
+
const portSource = bridgePort !== undefined ? 'explicit' : 'auto';
|
|
649
|
+
logDebug(`Running Godot project: ${projectPath} (bridge port ${port}, ${portSource})`);
|
|
503
650
|
const sessionToken = randomBytes(16).toString('hex');
|
|
504
651
|
const spawnOptions = {
|
|
505
652
|
stdio: 'pipe',
|
|
506
|
-
env: {
|
|
653
|
+
env: {
|
|
654
|
+
...process.env,
|
|
655
|
+
MCP_SESSION_TOKEN: sessionToken,
|
|
656
|
+
},
|
|
507
657
|
};
|
|
508
658
|
if (background) {
|
|
509
659
|
spawnOptions.env = { ...spawnOptions.env, MCP_BACKGROUND: '1' };
|
|
@@ -555,33 +705,57 @@ export class GodotRunner {
|
|
|
555
705
|
this.activeProcess = godotProcess;
|
|
556
706
|
return this.activeProcess;
|
|
557
707
|
}
|
|
558
|
-
attachProject(projectPath) {
|
|
708
|
+
async attachProject(projectPath, bridgePort) {
|
|
559
709
|
if (this.activeSessionMode === 'spawned' && this.activeProcess) {
|
|
560
|
-
this.stopProject();
|
|
710
|
+
await this.stopProject();
|
|
561
711
|
}
|
|
562
712
|
else if (this.activeSessionMode === 'attached' &&
|
|
563
713
|
this.activeProjectPath &&
|
|
564
714
|
this.activeProjectPath !== projectPath) {
|
|
565
|
-
|
|
715
|
+
// Different project — detach the old one cleanly so its bridge
|
|
716
|
+
// releases the port before we inject into the new project.
|
|
717
|
+
try {
|
|
718
|
+
await this.sendCommand('shutdown', {}, BRIDGE_SHUTDOWN_ATTACHED_TIMEOUT_MS);
|
|
719
|
+
}
|
|
720
|
+
catch (err) {
|
|
721
|
+
logDebug(`Shutdown command failed during attach swap (ignored): ${err}`);
|
|
722
|
+
}
|
|
723
|
+
this.closeConnection();
|
|
724
|
+
this.bridge.cleanup(this.activeProjectPath);
|
|
566
725
|
this.activeProjectPath = null;
|
|
567
726
|
this.activeSessionMode = null;
|
|
568
727
|
}
|
|
569
|
-
|
|
728
|
+
const port = bridgePort ?? (await findFreePort());
|
|
729
|
+
this.activeBridgePort = port;
|
|
730
|
+
this.bridge.inject(projectPath, port);
|
|
731
|
+
const portSource = bridgePort !== undefined ? 'explicit' : 'auto';
|
|
732
|
+
logDebug(`Attaching to Godot project: ${projectPath} (bridge port ${port}, ${portSource})`);
|
|
570
733
|
this.activeProjectPath = projectPath;
|
|
571
734
|
this.activeSessionMode = 'attached';
|
|
572
735
|
this.activeProcess = null;
|
|
573
736
|
}
|
|
574
|
-
stopProject() {
|
|
737
|
+
async stopProject() {
|
|
575
738
|
if (!this.activeSessionMode) {
|
|
576
739
|
return null;
|
|
577
740
|
}
|
|
578
741
|
if (this.activeSessionMode === 'attached') {
|
|
742
|
+
// Ask the bridge to shut down so the user's still-running Godot
|
|
743
|
+
// releases the port. A timeout here is non-fatal — same end state
|
|
744
|
+
// as today, the bridge dies when the user closes Godot.
|
|
745
|
+
try {
|
|
746
|
+
await this.sendCommand('shutdown', {}, BRIDGE_SHUTDOWN_ATTACHED_TIMEOUT_MS);
|
|
747
|
+
}
|
|
748
|
+
catch (err) {
|
|
749
|
+
logDebug(`Attached shutdown timed out or failed (continuing cleanup): ${err}`);
|
|
750
|
+
}
|
|
751
|
+
this.closeConnection();
|
|
579
752
|
const projectPath = this.activeProjectPath;
|
|
580
753
|
if (projectPath) {
|
|
581
|
-
this.
|
|
754
|
+
this.bridge.cleanup(projectPath);
|
|
582
755
|
}
|
|
583
756
|
this.activeProjectPath = null;
|
|
584
757
|
this.activeSessionMode = null;
|
|
758
|
+
this.activeBridgePort = null;
|
|
585
759
|
this.activeProcess = null;
|
|
586
760
|
return {
|
|
587
761
|
mode: 'attached',
|
|
@@ -593,8 +767,36 @@ export class GodotRunner {
|
|
|
593
767
|
if (!this.activeProcess) {
|
|
594
768
|
return null;
|
|
595
769
|
}
|
|
770
|
+
// Spawned: try graceful shutdown so the bridge releases the port,
|
|
771
|
+
// then ensure the process actually exits.
|
|
772
|
+
try {
|
|
773
|
+
await this.sendCommand('shutdown', {}, BRIDGE_SHUTDOWN_SPAWNED_TIMEOUT_MS);
|
|
774
|
+
}
|
|
775
|
+
catch {
|
|
776
|
+
// Bridge may already be unreachable — proceed to kill.
|
|
777
|
+
}
|
|
778
|
+
this.closeConnection();
|
|
596
779
|
logDebug('Stopping active Godot process');
|
|
597
|
-
this.activeProcess.process
|
|
780
|
+
const proc = this.activeProcess.process;
|
|
781
|
+
proc.kill();
|
|
782
|
+
// Wait up to BRIDGE_PROCESS_EXIT_TIMEOUT_MS for graceful exit; otherwise SIGKILL.
|
|
783
|
+
if (!this.activeProcess.hasExited) {
|
|
784
|
+
await new Promise((resolve) => {
|
|
785
|
+
const timer = setTimeout(() => {
|
|
786
|
+
try {
|
|
787
|
+
proc.kill('SIGKILL');
|
|
788
|
+
}
|
|
789
|
+
catch {
|
|
790
|
+
// already dead
|
|
791
|
+
}
|
|
792
|
+
resolve();
|
|
793
|
+
}, BRIDGE_PROCESS_EXIT_TIMEOUT_MS);
|
|
794
|
+
proc.once('exit', () => {
|
|
795
|
+
clearTimeout(timer);
|
|
796
|
+
resolve();
|
|
797
|
+
});
|
|
798
|
+
});
|
|
799
|
+
}
|
|
598
800
|
const result = {
|
|
599
801
|
mode: 'spawned',
|
|
600
802
|
output: this.activeProcess.output,
|
|
@@ -602,10 +804,11 @@ export class GodotRunner {
|
|
|
602
804
|
};
|
|
603
805
|
this.activeProcess = null;
|
|
604
806
|
if (this.activeProjectPath) {
|
|
605
|
-
this.
|
|
807
|
+
this.bridge.cleanup(this.activeProjectPath);
|
|
606
808
|
this.activeProjectPath = null;
|
|
607
809
|
}
|
|
608
810
|
this.activeSessionMode = null;
|
|
811
|
+
this.activeBridgePort = null;
|
|
609
812
|
return result;
|
|
610
813
|
}
|
|
611
814
|
hasActiveRuntimeSession() {
|
|
@@ -617,165 +820,167 @@ export class GodotRunner {
|
|
|
617
820
|
}
|
|
618
821
|
return true;
|
|
619
822
|
}
|
|
620
|
-
removeAutoloadEntry(projectPath, entryName, scriptFilename) {
|
|
621
|
-
try {
|
|
622
|
-
const projectFile = join(projectPath, 'project.godot');
|
|
623
|
-
if (existsSync(projectFile)) {
|
|
624
|
-
let content = readFileSync(projectFile, 'utf8');
|
|
625
|
-
const autoloadEntry = `${entryName}="*res://${scriptFilename}"`;
|
|
626
|
-
if (content.includes(autoloadEntry)) {
|
|
627
|
-
content = content.replace(new RegExp(`\\n?${autoloadEntry.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, 'g'), '');
|
|
628
|
-
content = content.replace(/\[autoload\]\s*(?=\n\[|\n*$)/g, '');
|
|
629
|
-
content = content.trimEnd() + '\n';
|
|
630
|
-
writeFileSync(projectFile, content, 'utf8');
|
|
631
|
-
logDebug(`Removed ${entryName} autoload from project.godot`);
|
|
632
|
-
}
|
|
633
|
-
}
|
|
634
|
-
}
|
|
635
|
-
catch (err) {
|
|
636
|
-
logDebug(`Non-fatal: Failed to clean ${entryName} from project.godot: ${err}`);
|
|
637
|
-
}
|
|
638
|
-
try {
|
|
639
|
-
const scriptFile = join(projectPath, scriptFilename);
|
|
640
|
-
if (existsSync(scriptFile)) {
|
|
641
|
-
unlinkSync(scriptFile);
|
|
642
|
-
logDebug(`Removed ${scriptFilename} from project`);
|
|
643
|
-
}
|
|
644
|
-
}
|
|
645
|
-
catch (err) {
|
|
646
|
-
logDebug(`Non-fatal: Failed to remove ${scriptFilename}: ${err}`);
|
|
647
|
-
}
|
|
648
|
-
try {
|
|
649
|
-
const uidFile = join(projectPath, `${scriptFilename}.uid`);
|
|
650
|
-
if (existsSync(uidFile)) {
|
|
651
|
-
unlinkSync(uidFile);
|
|
652
|
-
logDebug(`Removed ${scriptFilename}.uid from project`);
|
|
653
|
-
}
|
|
654
|
-
}
|
|
655
|
-
catch (err) {
|
|
656
|
-
logDebug(`Non-fatal: Failed to remove ${scriptFilename}.uid: ${err}`);
|
|
657
|
-
}
|
|
658
|
-
}
|
|
659
823
|
/**
|
|
660
|
-
*
|
|
661
|
-
*
|
|
824
|
+
* Send a JSON command to the McpBridge over a long-lived TCP connection.
|
|
825
|
+
*
|
|
826
|
+
* MCP serializes tool calls so we hold one in-flight command at a time. The
|
|
827
|
+
* socket is lazy-connected on first call and persists across commands until
|
|
828
|
+
* `closeConnection` (or a peer-side close). A close mid-flight rejects with
|
|
829
|
+
* `BridgeDisconnectedError`; a per-command timeout rejects but does NOT
|
|
830
|
+
* close the socket — a slow command does not invalidate the session.
|
|
662
831
|
*/
|
|
663
|
-
injectBridgeAutoload(projectPath) {
|
|
664
|
-
if (this.injectedProjects.has(projectPath)) {
|
|
665
|
-
logDebug('Bridge already injected for this project, skipping');
|
|
666
|
-
return;
|
|
667
|
-
}
|
|
668
|
-
// Ensure .mcp/ directory exists with .gdignore so Godot skips it
|
|
669
|
-
const mcpDir = join(projectPath, '.mcp');
|
|
670
|
-
if (!existsSync(mcpDir)) {
|
|
671
|
-
mkdirSync(mcpDir, { recursive: true });
|
|
672
|
-
}
|
|
673
|
-
writeFileSync(join(mcpDir, '.gdignore'), '', 'utf8');
|
|
674
|
-
logDebug('Created .mcp/.gdignore');
|
|
675
|
-
// Also add .mcp/ to .gitignore if not already present
|
|
676
|
-
const gitignorePath = join(projectPath, '.gitignore');
|
|
677
|
-
const mcpGitignoreEntry = '.mcp/';
|
|
678
|
-
if (existsSync(gitignorePath)) {
|
|
679
|
-
const gitignoreContent = readFileSync(gitignorePath, 'utf8');
|
|
680
|
-
if (!gitignoreContent.includes(mcpGitignoreEntry)) {
|
|
681
|
-
const newline = gitignoreContent.endsWith('\n') ? '' : '\n';
|
|
682
|
-
writeFileSync(gitignorePath, gitignoreContent + newline + mcpGitignoreEntry + '\n', 'utf8');
|
|
683
|
-
logDebug('Added .mcp/ to existing .gitignore');
|
|
684
|
-
}
|
|
685
|
-
}
|
|
686
|
-
else {
|
|
687
|
-
writeFileSync(gitignorePath, mcpGitignoreEntry + '\n', 'utf8');
|
|
688
|
-
logDebug('Created .gitignore with .mcp/ entry');
|
|
689
|
-
}
|
|
690
|
-
// Clean up legacy screenshot server if present
|
|
691
|
-
this.removeAutoloadEntry(projectPath, 'McpScreenshotServer', 'mcp_screenshot_server.gd');
|
|
692
|
-
const destScript = join(projectPath, 'mcp_bridge.gd');
|
|
693
|
-
copyFileSync(this.bridgeScriptPath, destScript);
|
|
694
|
-
logDebug(`Copied bridge autoload to ${destScript}`);
|
|
695
|
-
const projectFile = join(projectPath, 'project.godot');
|
|
696
|
-
let content = readFileSync(projectFile, 'utf8');
|
|
697
|
-
const autoloadEntry = 'McpBridge="*res://mcp_bridge.gd"';
|
|
698
|
-
if (content.includes(autoloadEntry)) {
|
|
699
|
-
logDebug('Bridge autoload already present, skipping injection');
|
|
700
|
-
if (!existsSync(destScript)) {
|
|
701
|
-
copyFileSync(this.bridgeScriptPath, destScript);
|
|
702
|
-
logDebug('Re-copied missing bridge script');
|
|
703
|
-
}
|
|
704
|
-
this.injectedProjects.add(projectPath);
|
|
705
|
-
return;
|
|
706
|
-
}
|
|
707
|
-
const autoloadSectionRegex = /^\[autoload\]\s*$/m;
|
|
708
|
-
if (autoloadSectionRegex.test(content)) {
|
|
709
|
-
content = content.replace(autoloadSectionRegex, `[autoload]\n${autoloadEntry}`);
|
|
710
|
-
}
|
|
711
|
-
else {
|
|
712
|
-
content = content.trimEnd() + `\n\n[autoload]\n${autoloadEntry}\n`;
|
|
713
|
-
}
|
|
714
|
-
writeFileSync(projectFile, content, 'utf8');
|
|
715
|
-
logDebug('Injected bridge autoload into project.godot');
|
|
716
|
-
this.injectedProjects.add(projectPath);
|
|
717
|
-
}
|
|
718
|
-
cleanupBridgeAutoload(projectPath) {
|
|
719
|
-
this.removeAutoloadEntry(projectPath, 'McpBridge', 'mcp_bridge.gd');
|
|
720
|
-
this.injectedProjects.delete(projectPath);
|
|
721
|
-
}
|
|
722
|
-
repairOrphanedBridge(projectPath) {
|
|
723
|
-
const projectFile = join(projectPath, 'project.godot');
|
|
724
|
-
const bridgeScript = join(projectPath, 'mcp_bridge.gd');
|
|
725
|
-
if (!existsSync(projectFile))
|
|
726
|
-
return;
|
|
727
|
-
if (existsSync(bridgeScript))
|
|
728
|
-
return;
|
|
729
|
-
try {
|
|
730
|
-
const content = readFileSync(projectFile, 'utf8');
|
|
731
|
-
if (content.includes('McpBridge=')) {
|
|
732
|
-
this.removeAutoloadEntry(projectPath, 'McpBridge', 'mcp_bridge.gd');
|
|
733
|
-
logDebug('Cleaned up orphaned McpBridge autoload entry');
|
|
734
|
-
}
|
|
735
|
-
}
|
|
736
|
-
catch (err) {
|
|
737
|
-
logDebug(`Non-fatal: Failed to check/repair orphaned bridge: ${err}`);
|
|
738
|
-
}
|
|
739
|
-
}
|
|
740
832
|
sendCommand(command, params = {}, timeoutMs = 10000) {
|
|
741
833
|
return new Promise((resolve, reject) => {
|
|
742
|
-
|
|
743
|
-
|
|
834
|
+
if (this.inFlight) {
|
|
835
|
+
reject(new Error(`Command '${command}' rejected: another command ('${this.inFlight.command}') is in flight`));
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
const settle = (err, value) => {
|
|
839
|
+
if (!this.inFlight)
|
|
840
|
+
return;
|
|
841
|
+
const flight = this.inFlight;
|
|
842
|
+
this.inFlight = null;
|
|
843
|
+
clearTimeout(flight.timer);
|
|
844
|
+
if (err) {
|
|
845
|
+
flight.reject(err);
|
|
846
|
+
}
|
|
847
|
+
else {
|
|
848
|
+
flight.resolve(value ?? '');
|
|
849
|
+
}
|
|
850
|
+
};
|
|
744
851
|
const timer = setTimeout(() => {
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
852
|
+
// Destroy the socket on timeout. The bridge serializes commands
|
|
853
|
+
// (peer.handling gate), so a slow command's late response would
|
|
854
|
+
// otherwise correlate against the next command we send. The next
|
|
855
|
+
// sendCommand lazy-reconnects.
|
|
856
|
+
if (this.socket) {
|
|
857
|
+
const sock = this.socket;
|
|
858
|
+
this.socket = null;
|
|
859
|
+
sock.removeAllListeners();
|
|
860
|
+
sock.destroy();
|
|
749
861
|
}
|
|
862
|
+
this.resetRxBuffer();
|
|
863
|
+
settle(new Error(`Command '${command}' timed out after ${timeoutMs}ms. Is the game running?`));
|
|
750
864
|
}, timeoutMs);
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
resolve(msg.toString('utf8'));
|
|
865
|
+
this.inFlight = { command, resolve, reject, timer };
|
|
866
|
+
const ensureSocket = (cb) => {
|
|
867
|
+
if (this.socket) {
|
|
868
|
+
cb();
|
|
869
|
+
return;
|
|
757
870
|
}
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
871
|
+
// Fallback to DEFAULT_BRIDGE_PORT is defensive — every entry point
|
|
872
|
+
// (runProject, attachProject) sets activeBridgePort before sendCommand
|
|
873
|
+
// can be reached, so this branch is not expected in practice.
|
|
874
|
+
const port = this.activeBridgePort ?? DEFAULT_BRIDGE_PORT;
|
|
875
|
+
const sock = net.connect(port, '127.0.0.1');
|
|
876
|
+
const onConnect = () => {
|
|
877
|
+
sock.setNoDelay(true);
|
|
878
|
+
sock.removeListener('error', onConnectError);
|
|
879
|
+
this.socket = sock;
|
|
880
|
+
this.resetRxBuffer();
|
|
881
|
+
sock.on('data', (chunk) => {
|
|
882
|
+
this.rxChunks.push(chunk);
|
|
883
|
+
this.rxTotal += chunk.length;
|
|
884
|
+
// Defer the (potentially expensive) concat until we know at least
|
|
885
|
+
// one complete frame is ready. Peek the 4-byte header without
|
|
886
|
+
// copying all accumulated chunks first.
|
|
887
|
+
if (this.rxTotal < FRAME_HEADER_BYTES)
|
|
888
|
+
return;
|
|
889
|
+
const header = readBytesFromChunks(this.rxChunks, FRAME_HEADER_BYTES);
|
|
890
|
+
const firstLen = header.readUInt32BE(0);
|
|
891
|
+
if (firstLen > MAX_FRAME_BYTES) {
|
|
892
|
+
this.socket = null;
|
|
893
|
+
sock.destroy();
|
|
894
|
+
settle(new BridgeDisconnectedError(`Bridge frame header advertises ${firstLen} bytes, exceeds limit ${MAX_FRAME_BYTES}`));
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
if (this.rxTotal < FRAME_HEADER_BYTES + firstLen)
|
|
898
|
+
return;
|
|
899
|
+
try {
|
|
900
|
+
const buffer = this.rxChunks.length === 1
|
|
901
|
+
? this.rxChunks[0]
|
|
902
|
+
: Buffer.concat(this.rxChunks, this.rxTotal);
|
|
903
|
+
const { frames, remainder } = parseFrames(buffer);
|
|
904
|
+
if (remainder.length === 0) {
|
|
905
|
+
this.rxChunks = [];
|
|
906
|
+
this.rxTotal = 0;
|
|
907
|
+
}
|
|
908
|
+
else {
|
|
909
|
+
this.rxChunks = [remainder];
|
|
910
|
+
this.rxTotal = remainder.length;
|
|
911
|
+
}
|
|
912
|
+
for (const frame of frames) {
|
|
913
|
+
settle(null, frame.toString('utf8'));
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
catch (parseErr) {
|
|
917
|
+
const message = parseErr instanceof Error ? parseErr.message : String(parseErr);
|
|
918
|
+
this.socket = null;
|
|
919
|
+
sock.destroy();
|
|
920
|
+
settle(new BridgeDisconnectedError(`Bridge framing error: ${message}`));
|
|
921
|
+
}
|
|
922
|
+
});
|
|
923
|
+
const onClose = () => {
|
|
924
|
+
this.socket = null;
|
|
925
|
+
settle(new BridgeDisconnectedError(`Bridge connection closed before '${command}' response was received`));
|
|
926
|
+
};
|
|
927
|
+
sock.once('close', onClose);
|
|
928
|
+
sock.on('error', (sockErr) => {
|
|
929
|
+
this.socket = null;
|
|
930
|
+
settle(new BridgeDisconnectedError(`Bridge socket error during '${command}': ${sockErr.message}`));
|
|
931
|
+
});
|
|
932
|
+
cb();
|
|
933
|
+
};
|
|
934
|
+
const onConnectError = (connErr) => {
|
|
935
|
+
sock.destroy();
|
|
936
|
+
cb(connErr);
|
|
937
|
+
};
|
|
938
|
+
sock.once('connect', onConnect);
|
|
939
|
+
sock.once('error', onConnectError);
|
|
940
|
+
};
|
|
941
|
+
ensureSocket((err) => {
|
|
942
|
+
if (err) {
|
|
943
|
+
settle(new BridgeDisconnectedError(`Failed to connect to bridge for '${command}': ${err.message}`));
|
|
944
|
+
return;
|
|
765
945
|
}
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
946
|
+
if (!this.socket) {
|
|
947
|
+
settle(new BridgeDisconnectedError(`Bridge socket unavailable for '${command}'`));
|
|
948
|
+
return;
|
|
949
|
+
}
|
|
950
|
+
try {
|
|
951
|
+
const payload = JSON.stringify({ command, ...params });
|
|
952
|
+
this.socket.write(encodeFrame(payload));
|
|
953
|
+
}
|
|
954
|
+
catch (writeErr) {
|
|
955
|
+
const message = writeErr instanceof Error ? writeErr.message : String(writeErr);
|
|
956
|
+
settle(new Error(`Failed to send command '${command}': ${message}`));
|
|
775
957
|
}
|
|
776
958
|
});
|
|
777
959
|
});
|
|
778
960
|
}
|
|
961
|
+
/**
|
|
962
|
+
* Tear down the bridge socket. Idempotent. Any in-flight command is
|
|
963
|
+
* rejected with a session-ended error.
|
|
964
|
+
*/
|
|
965
|
+
closeConnection() {
|
|
966
|
+
if (this.inFlight) {
|
|
967
|
+
const flight = this.inFlight;
|
|
968
|
+
this.inFlight = null;
|
|
969
|
+
clearTimeout(flight.timer);
|
|
970
|
+
flight.reject(new BridgeDisconnectedError('Bridge session ended'));
|
|
971
|
+
}
|
|
972
|
+
if (this.socket) {
|
|
973
|
+
const sock = this.socket;
|
|
974
|
+
this.socket = null;
|
|
975
|
+
sock.removeAllListeners();
|
|
976
|
+
sock.destroy();
|
|
977
|
+
}
|
|
978
|
+
this.resetRxBuffer();
|
|
979
|
+
}
|
|
980
|
+
resetRxBuffer() {
|
|
981
|
+
this.rxChunks = [];
|
|
982
|
+
this.rxTotal = 0;
|
|
983
|
+
}
|
|
779
984
|
getErrorCount() {
|
|
780
985
|
return this.activeProcess?.totalErrorsWritten ?? 0;
|
|
781
986
|
}
|
|
@@ -789,37 +994,64 @@ export class GodotRunner {
|
|
|
789
994
|
const window = delta >= errors.length ? errors.slice() : errors.slice(errors.length - delta);
|
|
790
995
|
return window.filter((line) => line.trim() !== '');
|
|
791
996
|
}
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
];
|
|
997
|
+
// Only the explicit `SCRIPT ERROR:` / `USER SCRIPT ERROR:` markers belong here — the looser
|
|
998
|
+
// `GDScript error` substring also matches user printerr output and produces false positives.
|
|
999
|
+
static SCRIPT_ERROR_PATTERNS = ['SCRIPT ERROR:', 'USER SCRIPT ERROR:'];
|
|
1000
|
+
static RETRYABLE_BRIDGE_COMMANDS = new Set(['get_ui_elements', 'screenshot']);
|
|
797
1001
|
extractRuntimeErrors(lines) {
|
|
798
1002
|
return lines.filter((line) => GodotRunner.SCRIPT_ERROR_PATTERNS.some((p) => line.includes(p)));
|
|
799
1003
|
}
|
|
1004
|
+
async sendCommandWithReconnect(command, params = {}, timeoutMs = 10000) {
|
|
1005
|
+
try {
|
|
1006
|
+
return await this.sendCommand(command, params, timeoutMs);
|
|
1007
|
+
}
|
|
1008
|
+
catch (err) {
|
|
1009
|
+
if (err instanceof BridgeDisconnectedError &&
|
|
1010
|
+
this.activeSessionMode &&
|
|
1011
|
+
GodotRunner.RETRYABLE_BRIDGE_COMMANDS.has(command)) {
|
|
1012
|
+
this.closeConnection();
|
|
1013
|
+
await new Promise((r) => setTimeout(r, BRIDGE_RECONNECT_DELAY_MS));
|
|
1014
|
+
return this.sendCommand(command, params, timeoutMs);
|
|
1015
|
+
}
|
|
1016
|
+
throw err;
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
800
1019
|
async sendCommandWithErrors(command, params = {}, timeoutMs = 10000) {
|
|
801
1020
|
const marker = this.getErrorCount();
|
|
802
|
-
const response = await this.
|
|
1021
|
+
const response = await this.sendCommandWithReconnect(command, params, timeoutMs);
|
|
803
1022
|
const newErrors = this.getErrorsSince(marker);
|
|
804
1023
|
const runtimeErrors = this.activeSessionMode === 'spawned' ? this.extractRuntimeErrors(newErrors) : [];
|
|
805
1024
|
return { response, runtimeErrors };
|
|
806
1025
|
}
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
1026
|
+
/**
|
|
1027
|
+
* Shared poll loop for `waitForBridge` (spawned) and `waitForBridgeAttached`.
|
|
1028
|
+
* Sends `ping` payloads until the bridge replies with a pong that
|
|
1029
|
+
* `validatePong` accepts, the deadline passes, or `shouldAbort` reports
|
|
1030
|
+
* the spawned process has exited.
|
|
1031
|
+
*/
|
|
1032
|
+
async pollBridge(opts) {
|
|
1033
|
+
const deadline = Date.now() + opts.timeoutMs;
|
|
812
1034
|
while (Date.now() < deadline) {
|
|
1035
|
+
if (opts.shouldAbort) {
|
|
1036
|
+
const abort = opts.shouldAbort();
|
|
1037
|
+
if (abort.aborted) {
|
|
1038
|
+
const errorText = abort.tail.length > 0 ? `\nLast stderr:\n${abort.tail.join('\n')}` : '';
|
|
1039
|
+
return {
|
|
1040
|
+
ready: false,
|
|
1041
|
+
error: `Process exited with code ${this.activeProcess?.exitCode ?? '?'} before bridge was ready.${errorText}`,
|
|
1042
|
+
};
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
813
1045
|
try {
|
|
814
|
-
const response = await this.sendCommand('ping',
|
|
1046
|
+
const response = await this.sendCommand('ping', opts.pingPayload, BRIDGE_PING_TIMEOUT_MS);
|
|
815
1047
|
const parsed = JSON.parse(response);
|
|
816
|
-
if (parsed
|
|
817
|
-
if (expectedPath && parsed.project_path) {
|
|
1048
|
+
if (opts.validatePong(parsed)) {
|
|
1049
|
+
if (opts.expectedPath && parsed.project_path) {
|
|
818
1050
|
const bridgePath = normalizeForCompare(parsed.project_path);
|
|
819
|
-
if (bridgePath !== expectedPath) {
|
|
1051
|
+
if (bridgePath !== opts.expectedPath) {
|
|
820
1052
|
return {
|
|
821
1053
|
ready: false,
|
|
822
|
-
error: `Bridge
|
|
1054
|
+
error: `Bridge reports project ${bridgePath}, expected ${opts.expectedPath}`,
|
|
823
1055
|
};
|
|
824
1056
|
}
|
|
825
1057
|
}
|
|
@@ -829,56 +1061,37 @@ export class GodotRunner {
|
|
|
829
1061
|
catch {
|
|
830
1062
|
// Expected: ping will fail until bridge is listening
|
|
831
1063
|
}
|
|
832
|
-
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
1064
|
+
await new Promise((resolve) => setTimeout(resolve, opts.intervalMs));
|
|
833
1065
|
}
|
|
834
|
-
return {
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
1066
|
+
return { ready: false, error: opts.timeoutError };
|
|
1067
|
+
}
|
|
1068
|
+
async waitForBridgeAttached(timeoutMs = BRIDGE_WAIT_ATTACHED_TIMEOUT_MS, intervalMs = BRIDGE_WAIT_ATTACHED_INTERVAL_MS) {
|
|
1069
|
+
return this.pollBridge({
|
|
1070
|
+
expectedPath: this.activeProjectPath ? normalizeForCompare(this.activeProjectPath) : null,
|
|
1071
|
+
timeoutMs,
|
|
1072
|
+
intervalMs,
|
|
1073
|
+
timeoutError: 'Bridge did not respond within timeout — is Godot running with the McpBridge autoload?',
|
|
1074
|
+
pingPayload: {},
|
|
1075
|
+
validatePong: (parsed) => parsed.status === 'pong',
|
|
1076
|
+
});
|
|
838
1077
|
}
|
|
839
1078
|
async waitForBridge(timeoutMs = BRIDGE_WAIT_SPAWNED_TIMEOUT_MS, intervalMs = BRIDGE_WAIT_SPAWNED_INTERVAL_MS) {
|
|
840
|
-
const deadline = Date.now() + timeoutMs;
|
|
841
1079
|
const expectedToken = this.activeProcess?.sessionToken;
|
|
842
|
-
const expectedPath = this.activeProjectPath
|
|
843
|
-
? normalizeForCompare(this.activeProjectPath)
|
|
844
|
-
: null;
|
|
845
1080
|
if (!expectedToken) {
|
|
846
1081
|
return { ready: false, error: 'No active spawned Godot process to verify' };
|
|
847
1082
|
}
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
if (parsed.status === 'pong' && parsed.session_token === expectedToken) {
|
|
861
|
-
if (expectedPath && parsed.project_path) {
|
|
862
|
-
const bridgePath = normalizeForCompare(parsed.project_path);
|
|
863
|
-
if (bridgePath !== expectedPath) {
|
|
864
|
-
return {
|
|
865
|
-
ready: false,
|
|
866
|
-
error: `Bridge reports project ${bridgePath}, expected ${expectedPath}`,
|
|
867
|
-
};
|
|
868
|
-
}
|
|
869
|
-
}
|
|
870
|
-
return { ready: true };
|
|
871
|
-
}
|
|
872
|
-
}
|
|
873
|
-
catch {
|
|
874
|
-
// Expected: ping will fail until bridge is listening
|
|
875
|
-
}
|
|
876
|
-
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
877
|
-
}
|
|
878
|
-
return {
|
|
879
|
-
ready: false,
|
|
880
|
-
error: 'Bridge did not respond with the expected session token within timeout',
|
|
881
|
-
};
|
|
1083
|
+
return this.pollBridge({
|
|
1084
|
+
expectedPath: this.activeProjectPath ? normalizeForCompare(this.activeProjectPath) : null,
|
|
1085
|
+
timeoutMs,
|
|
1086
|
+
intervalMs,
|
|
1087
|
+
timeoutError: 'Bridge did not respond with the expected session token within timeout',
|
|
1088
|
+
pingPayload: { session_token: expectedToken },
|
|
1089
|
+
validatePong: (parsed) => parsed.status === 'pong' && parsed.session_token === expectedToken,
|
|
1090
|
+
shouldAbort: () => ({
|
|
1091
|
+
aborted: this.activeProcess !== null && this.activeProcess.hasExited,
|
|
1092
|
+
tail: this.getRecentErrors(20),
|
|
1093
|
+
}),
|
|
1094
|
+
});
|
|
882
1095
|
}
|
|
883
1096
|
getRecentErrors(count = 20) {
|
|
884
1097
|
if (!this.activeProcess)
|