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.
Files changed (59) hide show
  1. package/README.md +46 -132
  2. package/dist/dispatch.d.ts +1 -11
  3. package/dist/dispatch.d.ts.map +1 -1
  4. package/dist/dispatch.js +32 -33
  5. package/dist/dispatch.js.map +1 -1
  6. package/dist/index.d.ts +1 -1
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +12 -10
  9. package/dist/index.js.map +1 -1
  10. package/dist/scripts/godot_operations.gd +268 -382
  11. package/dist/scripts/mcp_bridge.gd +206 -44
  12. package/dist/tools/autoload-tools.d.ts +51 -0
  13. package/dist/tools/autoload-tools.d.ts.map +1 -0
  14. package/dist/tools/autoload-tools.js +191 -0
  15. package/dist/tools/autoload-tools.js.map +1 -0
  16. package/dist/tools/node-tools.d.ts +9 -78
  17. package/dist/tools/node-tools.d.ts.map +1 -1
  18. package/dist/tools/node-tools.js +188 -312
  19. package/dist/tools/node-tools.js.map +1 -1
  20. package/dist/tools/project-tools.d.ts +0 -168
  21. package/dist/tools/project-tools.d.ts.map +1 -1
  22. package/dist/tools/project-tools.js +191 -1240
  23. package/dist/tools/project-tools.js.map +1 -1
  24. package/dist/tools/runtime-tools.d.ts +108 -0
  25. package/dist/tools/runtime-tools.d.ts.map +1 -0
  26. package/dist/tools/runtime-tools.js +994 -0
  27. package/dist/tools/runtime-tools.js.map +1 -0
  28. package/dist/tools/scene-tools.d.ts +6 -48
  29. package/dist/tools/scene-tools.d.ts.map +1 -1
  30. package/dist/tools/scene-tools.js +76 -212
  31. package/dist/tools/scene-tools.js.map +1 -1
  32. package/dist/tools/validate-tools.d.ts.map +1 -1
  33. package/dist/tools/validate-tools.js +115 -51
  34. package/dist/tools/validate-tools.js.map +1 -1
  35. package/dist/utils/autoload-ini.d.ts +38 -0
  36. package/dist/utils/autoload-ini.d.ts.map +1 -0
  37. package/dist/utils/autoload-ini.js +124 -0
  38. package/dist/utils/autoload-ini.js.map +1 -0
  39. package/dist/utils/bridge-manager.d.ts +46 -0
  40. package/dist/utils/bridge-manager.d.ts.map +1 -0
  41. package/dist/utils/bridge-manager.js +186 -0
  42. package/dist/utils/bridge-manager.js.map +1 -0
  43. package/dist/utils/bridge-protocol.d.ts +37 -0
  44. package/dist/utils/bridge-protocol.d.ts.map +1 -0
  45. package/dist/utils/bridge-protocol.js +78 -0
  46. package/dist/utils/bridge-protocol.js.map +1 -0
  47. package/dist/utils/godot-runner.d.ts +102 -16
  48. package/dist/utils/godot-runner.d.ts.map +1 -1
  49. package/dist/utils/godot-runner.js +497 -284
  50. package/dist/utils/godot-runner.js.map +1 -1
  51. package/dist/utils/handler-helpers.d.ts +34 -0
  52. package/dist/utils/handler-helpers.d.ts.map +1 -0
  53. package/dist/utils/handler-helpers.js +55 -0
  54. package/dist/utils/handler-helpers.js.map +1 -0
  55. package/dist/utils/logger.d.ts +4 -0
  56. package/dist/utils/logger.d.ts.map +1 -0
  57. package/dist/utils/logger.js +11 -0
  58. package/dist/utils/logger.js.map +1 -0
  59. 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, readFileSync, writeFileSync, copyFileSync, unlinkSync, mkdirSync } from 'fs';
2
+ import { join, dirname, normalize, resolve, sep } from 'path';
3
+ import { existsSync } from 'fs';
4
4
  import { spawn } from 'child_process';
5
- import { createSocket } from 'dgram';
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 snakeKey = reverseParameterMappings[key] ||
153
- key.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
154
- const value = params[key];
155
- if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
156
- result[snakeKey] = convertCamelToSnakeCase(value);
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
- result[snakeKey] = value;
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 = join(args.projectPath, 'project.godot');
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 (!validatePath(args.scenePath)) {
348
+ if (!validateSubPath(projectResult.projectPath, args.scenePath)) {
231
349
  return createErrorResponse('Invalid scene path', [
232
- 'Provide a valid path without ".." or other potentially unsafe characters',
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
- bridgeScriptPath;
387
+ bridge;
250
388
  validatedPaths = new Map();
251
- injectedProjects = new Set();
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
- this.bridgeScriptPath = join(__dirname, '..', 'scripts', 'mcp_bridge.gd');
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
- for (const path of possiblePaths) {
365
- const normalizedPath = normalize(path);
366
- if (await this.isValidGodotPath(normalizedPath)) {
367
- this.godotPath = normalizedPath;
368
- logDebug(`Found Godot at: ${normalizedPath}`);
369
- return;
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 (this.strictPathValidation) {
375
- throw new Error('Could not find a valid Godot executable. Set GODOT_PATH or provide a valid path in config.');
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
- if (osPlatform === 'win32') {
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
- return stdout.trim();
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.repairOrphanedBridge(projectPath);
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.cleanupBridgeAutoload(this.activeProjectPath);
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.cleanupBridgeAutoload(this.activeProjectPath);
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.injectBridgeAutoload(projectPath);
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 && validatePath(scene)) {
644
+ if (scene && validateSubPath(projectPath, scene)) {
499
645
  logDebug(`Adding scene parameter: ${scene}`);
500
646
  cmdArgs.push(scene);
501
647
  }
502
- logDebug(`Running Godot project: ${projectPath}`);
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: { ...process.env, MCP_SESSION_TOKEN: sessionToken },
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
- this.cleanupBridgeAutoload(this.activeProjectPath);
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
- this.injectBridgeAutoload(projectPath);
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.cleanupBridgeAutoload(projectPath);
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.kill();
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.cleanupBridgeAutoload(this.activeProjectPath);
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
- * Idempotent within a session: short-circuits on `injectedProjects` so a
661
- * second `attach_project`/`run_project` call does not rewrite `project.godot`.
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
- const socket = createSocket('udp4');
743
- let settled = false;
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
- if (!settled) {
746
- settled = true;
747
- socket.close();
748
- reject(new Error(`Command '${command}' timed out after ${timeoutMs}ms. Is the game running?`));
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
- socket.on('message', (msg) => {
752
- if (!settled) {
753
- settled = true;
754
- clearTimeout(timer);
755
- socket.close();
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
- socket.on('error', (err) => {
760
- if (!settled) {
761
- settled = true;
762
- clearTimeout(timer);
763
- socket.close();
764
- reject(new Error(`UDP error for command '${command}': ${err.message}`));
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
- const payload = JSON.stringify({ command, ...params });
768
- const message = Buffer.from(payload);
769
- socket.send(message, 9900, '127.0.0.1', (err) => {
770
- if (err && !settled) {
771
- settled = true;
772
- clearTimeout(timer);
773
- socket.close();
774
- reject(new Error(`Failed to send command '${command}': ${err.message}`));
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
- static SCRIPT_ERROR_PATTERNS = [
793
- 'SCRIPT ERROR:',
794
- 'USER SCRIPT ERROR:',
795
- 'GDScript error',
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.sendCommand(command, params, timeoutMs);
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
- async waitForBridgeAttached(timeoutMs = BRIDGE_WAIT_ATTACHED_TIMEOUT_MS, intervalMs = BRIDGE_WAIT_ATTACHED_INTERVAL_MS) {
808
- const deadline = Date.now() + timeoutMs;
809
- const expectedPath = this.activeProjectPath
810
- ? normalizeForCompare(this.activeProjectPath)
811
- : null;
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', {}, BRIDGE_PING_TIMEOUT_MS);
1046
+ const response = await this.sendCommand('ping', opts.pingPayload, BRIDGE_PING_TIMEOUT_MS);
815
1047
  const parsed = JSON.parse(response);
816
- if (parsed.status === 'pong') {
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 is running for a different project (${bridgePath}), expected ${expectedPath}`,
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
- ready: false,
836
- error: 'Bridge did not respond within timeout — is Godot running with the McpBridge autoload?',
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
- while (Date.now() < deadline) {
849
- if (this.activeProcess && this.activeProcess.hasExited) {
850
- const lastErrors = this.getRecentErrors(20);
851
- const errorText = lastErrors.length > 0 ? `\nLast stderr:\n${lastErrors.join('\n')}` : '';
852
- return {
853
- ready: false,
854
- error: `Process exited with code ${this.activeProcess.exitCode} before bridge was ready.${errorText}`,
855
- };
856
- }
857
- try {
858
- const response = await this.sendCommand('ping', { session_token: expectedToken }, BRIDGE_PING_TIMEOUT_MS);
859
- const parsed = JSON.parse(response);
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)