godot-mcp-runtime 2.2.3 → 3.0.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 +30 -93
  2. package/dist/dispatch.d.ts +1 -11
  3. package/dist/dispatch.d.ts.map +1 -1
  4. package/dist/dispatch.js +7 -8
  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 +134 -283
  11. package/dist/scripts/mcp_bridge.gd +210 -43
  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 +187 -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 +114 -310
  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 +120 -1192
  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 +808 -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 +67 -211
  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 +35 -29
  34. package/dist/tools/validate-tools.js.map +1 -1
  35. package/dist/utils/autoload-ini.d.ts +32 -0
  36. package/dist/utils/autoload-ini.d.ts.map +1 -0
  37. package/dist/utils/autoload-ini.js +111 -0
  38. package/dist/utils/autoload-ini.js.map +1 -0
  39. package/dist/utils/bridge-manager.d.ts +29 -0
  40. package/dist/utils/bridge-manager.d.ts.map +1 -0
  41. package/dist/utils/bridge-manager.js +136 -0
  42. package/dist/utils/bridge-manager.js.map +1 -0
  43. package/dist/utils/bridge-protocol.d.ts +34 -0
  44. package/dist/utils/bridge-protocol.d.ts.map +1 -0
  45. package/dist/utils/bridge-protocol.js +65 -0
  46. package/dist/utils/bridge-protocol.js.map +1 -0
  47. package/dist/utils/godot-runner.d.ts +70 -15
  48. package/dist/utils/godot-runner.d.ts.map +1 -1
  49. package/dist/utils/godot-runner.js +309 -277
  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 +3 -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 +7 -4
@@ -1,20 +1,36 @@
1
1
  import { fileURLToPath } from 'url';
2
2
  import { join, dirname, normalize } from 'path';
3
- import { existsSync, readFileSync, writeFileSync, copyFileSync, unlinkSync, mkdirSync } from 'fs';
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 { encodeFrame, getBridgePort, parseFrames } from './bridge-protocol.js';
9
+ import { logDebug, logError } 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
24
  const DEBUG_MODE = process.env.DEBUG === 'true';
12
25
  // Bridge readiness polling
13
- const BRIDGE_WAIT_SPAWNED_TIMEOUT_MS = 8000;
26
+ export const BRIDGE_WAIT_SPAWNED_TIMEOUT_MS = 8000;
14
27
  const BRIDGE_WAIT_SPAWNED_INTERVAL_MS = 300;
15
28
  const BRIDGE_WAIT_ATTACHED_TIMEOUT_MS = 15000;
16
29
  const BRIDGE_WAIT_ATTACHED_INTERVAL_MS = 500;
17
30
  const BRIDGE_PING_TIMEOUT_MS = 1000;
31
+ const BRIDGE_SHUTDOWN_SPAWNED_TIMEOUT_MS = 500;
32
+ const BRIDGE_SHUTDOWN_ATTACHED_TIMEOUT_MS = 1500;
33
+ const BRIDGE_PROCESS_EXIT_TIMEOUT_MS = 2000;
18
34
  /**
19
35
  * Normalize a path for cross-platform comparison.
20
36
  * Folds Windows backslashes to forward slashes and strips trailing slashes,
@@ -94,6 +110,12 @@ 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
98
120
  const parameterMappings = {
99
121
  project_path: 'projectPath',
@@ -109,20 +131,15 @@ const parameterMappings = {
109
131
  new_path: 'newPath',
110
132
  file_path: 'filePath',
111
133
  script_path: 'scriptPath',
134
+ response_mode: 'responseMode',
135
+ preview_max_width: 'previewMaxWidth',
136
+ preview_max_height: 'previewMaxHeight',
112
137
  };
113
138
  // Reverse mapping from camelCase to snake_case
114
139
  const reverseParameterMappings = {};
115
140
  for (const [snakeCase, camelCase] of Object.entries(parameterMappings)) {
116
141
  reverseParameterMappings[camelCase] = snakeCase;
117
142
  }
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
143
  export function normalizeParameters(params) {
127
144
  if (!params || typeof params !== 'object') {
128
145
  return params;
@@ -168,6 +185,21 @@ export function validatePath(path) {
168
185
  }
169
186
  return true;
170
187
  }
188
+ /**
189
+ * Return `error.message` when `error` is an `Error`, otherwise `'Unknown error'`.
190
+ * Centralizes the catch-block boilerplate so handlers can build error responses
191
+ * without repeating the `instanceof Error` ternary.
192
+ */
193
+ export function getErrorMessage(error) {
194
+ return error instanceof Error ? error.message : 'Unknown error';
195
+ }
196
+ /**
197
+ * Build the absolute path to a project's `project.godot` manifest. Use this
198
+ * instead of `join(dir, 'project.godot')` ad hoc.
199
+ */
200
+ export function projectGodotPath(projectDir) {
201
+ return join(projectDir, 'project.godot');
202
+ }
171
203
  /**
172
204
  * Extract the first [ERROR] message from GDScript stderr output.
173
205
  * Falls back to a generic message if no [ERROR] line is found.
@@ -206,7 +238,7 @@ export function validateProjectArgs(args) {
206
238
  'Provide a valid path without ".." or other potentially unsafe characters',
207
239
  ]);
208
240
  }
209
- const projectFile = join(args.projectPath, 'project.godot');
241
+ const projectFile = projectGodotPath(args.projectPath);
210
242
  if (!existsSync(projectFile)) {
211
243
  return createErrorResponse(`Not a valid Godot project: ${args.projectPath}`, [
212
244
  'Ensure the path points to a directory containing a project.godot file',
@@ -246,17 +278,19 @@ export function validateSceneArgs(args, opts) {
246
278
  export class GodotRunner {
247
279
  godotPath = null;
248
280
  operationsScriptPath;
249
- bridgeScriptPath;
281
+ bridge;
250
282
  validatedPaths = new Map();
251
- injectedProjects = new Set();
252
- strictPathValidation;
283
+ cachedVersion = null;
253
284
  activeProcess = null;
254
285
  activeProjectPath = null;
255
286
  activeSessionMode = null;
287
+ socket = null;
288
+ rxBuffer = Buffer.alloc(0);
289
+ inFlight = null;
256
290
  constructor(config) {
257
- this.strictPathValidation = config?.strictPathValidation ?? false;
258
291
  this.operationsScriptPath = join(__dirname, '..', 'scripts', 'godot_operations.gd');
259
- this.bridgeScriptPath = join(__dirname, '..', 'scripts', 'mcp_bridge.gd');
292
+ const bridgeScriptPath = join(__dirname, '..', 'scripts', 'mcp_bridge.gd');
293
+ this.bridge = new BridgeManager(bridgeScriptPath);
260
294
  logDebug(`Operations script path: ${this.operationsScriptPath}`);
261
295
  if (config?.godotPath) {
262
296
  const normalizedPath = normalize(config.godotPath);
@@ -361,36 +395,34 @@ export class GodotRunner {
361
395
  else if (osPlatform === 'linux') {
362
396
  possiblePaths.push('/usr/bin/godot', '/usr/local/bin/godot', '/snap/bin/godot', `${process.env.HOME}/.local/bin/godot`);
363
397
  }
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
- }
398
+ const normalizedCandidates = possiblePaths.map((p) => normalize(p));
399
+ const probeResults = await Promise.all(normalizedCandidates.map(async (p) => ({ path: p, valid: await this.isValidGodotPath(p) })));
400
+ const winner = probeResults.find((r) => r.valid);
401
+ if (winner) {
402
+ this.godotPath = winner.path;
403
+ logDebug(`Found Godot at: ${winner.path}`);
404
+ return;
371
405
  }
372
406
  logDebug(`Warning: Could not find Godot in common locations for ${osPlatform}`);
373
407
  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.');
408
+ if (osPlatform === 'win32') {
409
+ this.godotPath = normalize('C:\\Program Files\\Godot\\Godot.exe');
410
+ }
411
+ else if (osPlatform === 'darwin') {
412
+ this.godotPath = normalize('/Applications/Godot.app/Contents/MacOS/Godot');
376
413
  }
377
414
  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.`);
415
+ this.godotPath = normalize('/usr/bin/godot');
388
416
  }
417
+ logDebug(`Using default path: ${this.godotPath}, but this may not work.`);
389
418
  }
390
419
  getGodotPath() {
391
420
  return this.godotPath;
392
421
  }
393
422
  async getVersion() {
423
+ if (this.cachedVersion !== null) {
424
+ return this.cachedVersion;
425
+ }
394
426
  if (!this.godotPath) {
395
427
  await this.detectGodotPath();
396
428
  if (!this.godotPath) {
@@ -398,21 +430,13 @@ export class GodotRunner {
398
430
  }
399
431
  }
400
432
  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;
433
+ this.cachedVersion = stdout.trim();
434
+ return this.cachedVersion;
411
435
  }
412
436
  async executeOperation(operation, params, projectPath, timeoutMs = 30000) {
413
437
  logDebug(`Executing operation: ${operation} in project: ${projectPath}`);
414
438
  logDebug(`Original operation params: ${JSON.stringify(params)}`);
415
- this.repairOrphanedBridge(projectPath);
439
+ this.bridge.repairOrphaned(projectPath);
416
440
  const snakeCaseParams = convertCamelToSnakeCase(params);
417
441
  logDebug(`Converted snake_case params: ${JSON.stringify(snakeCaseParams)}`);
418
442
  if (!this.godotPath) {
@@ -433,12 +457,6 @@ export class GodotRunner {
433
457
  ...(DEBUG_MODE ? ['--debug-godot'] : []),
434
458
  ];
435
459
  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
460
  let stdout = '';
443
461
  let stderr = '';
444
462
  try {
@@ -476,18 +494,20 @@ export class GodotRunner {
476
494
  }
477
495
  if (this.activeSessionMode === 'spawned' && this.activeProcess) {
478
496
  logDebug('Killing existing Godot process before starting a new one');
497
+ this.closeConnection();
479
498
  this.activeProcess.process.kill();
480
499
  if (this.activeProjectPath && this.activeProjectPath !== projectPath) {
481
- this.cleanupBridgeAutoload(this.activeProjectPath);
500
+ this.bridge.cleanup(this.activeProjectPath);
482
501
  }
483
502
  }
484
503
  else if (this.activeSessionMode === 'attached' &&
485
504
  this.activeProjectPath &&
486
505
  this.activeProjectPath !== projectPath) {
487
- this.cleanupBridgeAutoload(this.activeProjectPath);
506
+ this.closeConnection();
507
+ this.bridge.cleanup(this.activeProjectPath);
488
508
  }
489
509
  try {
490
- this.injectBridgeAutoload(projectPath);
510
+ this.bridge.inject(projectPath);
491
511
  }
492
512
  catch (err) {
493
513
  logDebug(`Non-fatal: Failed to inject bridge autoload: ${err}`);
@@ -525,10 +545,12 @@ export class GodotRunner {
525
545
  output.push(...lines);
526
546
  if (output.length > 500)
527
547
  output.splice(0, output.length - 500);
528
- lines.forEach((line) => {
529
- if (line.trim())
530
- logDebug(`[Godot stdout] ${line}`);
531
- });
548
+ if (DEBUG_MODE) {
549
+ lines.forEach((line) => {
550
+ if (line.trim())
551
+ logDebug(`[Godot stdout] ${line}`);
552
+ });
553
+ }
532
554
  });
533
555
  proc.stderr?.on('data', (data) => {
534
556
  const lines = data.toString().split('\n');
@@ -536,10 +558,12 @@ export class GodotRunner {
536
558
  errors.push(...lines);
537
559
  if (errors.length > 500)
538
560
  errors.splice(0, errors.length - 500);
539
- lines.forEach((line) => {
540
- if (line.trim())
541
- logDebug(`[Godot stderr] ${line}`);
542
- });
561
+ if (DEBUG_MODE) {
562
+ lines.forEach((line) => {
563
+ if (line.trim())
564
+ logDebug(`[Godot stderr] ${line}`);
565
+ });
566
+ }
543
567
  });
544
568
  proc.on('exit', (code) => {
545
569
  logDebug(`Godot process exited with code ${code}`);
@@ -555,30 +579,49 @@ export class GodotRunner {
555
579
  this.activeProcess = godotProcess;
556
580
  return this.activeProcess;
557
581
  }
558
- attachProject(projectPath) {
582
+ async attachProject(projectPath) {
559
583
  if (this.activeSessionMode === 'spawned' && this.activeProcess) {
560
- this.stopProject();
584
+ await this.stopProject();
561
585
  }
562
586
  else if (this.activeSessionMode === 'attached' &&
563
587
  this.activeProjectPath &&
564
588
  this.activeProjectPath !== projectPath) {
565
- this.cleanupBridgeAutoload(this.activeProjectPath);
589
+ // Different project — detach the old one cleanly so its bridge
590
+ // releases the port before we inject into the new project.
591
+ try {
592
+ await this.sendCommand('shutdown', {}, BRIDGE_SHUTDOWN_ATTACHED_TIMEOUT_MS);
593
+ }
594
+ catch (err) {
595
+ logDebug(`Shutdown command failed during attach swap (ignored): ${err}`);
596
+ }
597
+ this.closeConnection();
598
+ this.bridge.cleanup(this.activeProjectPath);
566
599
  this.activeProjectPath = null;
567
600
  this.activeSessionMode = null;
568
601
  }
569
- this.injectBridgeAutoload(projectPath);
602
+ this.bridge.inject(projectPath);
570
603
  this.activeProjectPath = projectPath;
571
604
  this.activeSessionMode = 'attached';
572
605
  this.activeProcess = null;
573
606
  }
574
- stopProject() {
607
+ async stopProject() {
575
608
  if (!this.activeSessionMode) {
576
609
  return null;
577
610
  }
578
611
  if (this.activeSessionMode === 'attached') {
612
+ // Ask the bridge to shut down so the user's still-running Godot
613
+ // releases the port. A timeout here is non-fatal — same end state
614
+ // as today, the bridge dies when the user closes Godot.
615
+ try {
616
+ await this.sendCommand('shutdown', {}, BRIDGE_SHUTDOWN_ATTACHED_TIMEOUT_MS);
617
+ }
618
+ catch (err) {
619
+ logDebug(`Attached shutdown timed out or failed (continuing cleanup): ${err}`);
620
+ }
621
+ this.closeConnection();
579
622
  const projectPath = this.activeProjectPath;
580
623
  if (projectPath) {
581
- this.cleanupBridgeAutoload(projectPath);
624
+ this.bridge.cleanup(projectPath);
582
625
  }
583
626
  this.activeProjectPath = null;
584
627
  this.activeSessionMode = null;
@@ -593,8 +636,36 @@ export class GodotRunner {
593
636
  if (!this.activeProcess) {
594
637
  return null;
595
638
  }
639
+ // Spawned: try graceful shutdown so the bridge releases the port,
640
+ // then ensure the process actually exits.
641
+ try {
642
+ await this.sendCommand('shutdown', {}, BRIDGE_SHUTDOWN_SPAWNED_TIMEOUT_MS);
643
+ }
644
+ catch {
645
+ // Bridge may already be unreachable — proceed to kill.
646
+ }
647
+ this.closeConnection();
596
648
  logDebug('Stopping active Godot process');
597
- this.activeProcess.process.kill();
649
+ const proc = this.activeProcess.process;
650
+ proc.kill();
651
+ // Wait up to BRIDGE_PROCESS_EXIT_TIMEOUT_MS for graceful exit; otherwise SIGKILL.
652
+ if (!this.activeProcess.hasExited) {
653
+ await new Promise((resolve) => {
654
+ const timer = setTimeout(() => {
655
+ try {
656
+ proc.kill('SIGKILL');
657
+ }
658
+ catch {
659
+ // already dead
660
+ }
661
+ resolve();
662
+ }, BRIDGE_PROCESS_EXIT_TIMEOUT_MS);
663
+ proc.once('exit', () => {
664
+ clearTimeout(timer);
665
+ resolve();
666
+ });
667
+ });
668
+ }
598
669
  const result = {
599
670
  mode: 'spawned',
600
671
  output: this.activeProcess.output,
@@ -602,7 +673,7 @@ export class GodotRunner {
602
673
  };
603
674
  this.activeProcess = null;
604
675
  if (this.activeProjectPath) {
605
- this.cleanupBridgeAutoload(this.activeProjectPath);
676
+ this.bridge.cleanup(this.activeProjectPath);
606
677
  this.activeProjectPath = null;
607
678
  }
608
679
  this.activeSessionMode = null;
@@ -617,165 +688,134 @@ export class GodotRunner {
617
688
  }
618
689
  return true;
619
690
  }
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
691
  /**
660
- * Idempotent within a session: short-circuits on `injectedProjects` so a
661
- * second `attach_project`/`run_project` call does not rewrite `project.godot`.
692
+ * Send a JSON command to the McpBridge over a long-lived TCP connection.
693
+ *
694
+ * MCP serializes tool calls so we hold one in-flight command at a time. The
695
+ * socket is lazy-connected on first call and persists across commands until
696
+ * `closeConnection` (or a peer-side close). A close mid-flight rejects with
697
+ * `BridgeDisconnectedError`; a per-command timeout rejects but does NOT
698
+ * close the socket — a slow command does not invalidate the session.
662
699
  */
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
700
  sendCommand(command, params = {}, timeoutMs = 10000) {
741
701
  return new Promise((resolve, reject) => {
742
- const socket = createSocket('udp4');
743
- let settled = false;
702
+ if (this.inFlight) {
703
+ reject(new Error(`Command '${command}' rejected: another command ('${this.inFlight.command}') is in flight`));
704
+ return;
705
+ }
706
+ const settle = (err, value) => {
707
+ if (!this.inFlight)
708
+ return;
709
+ const flight = this.inFlight;
710
+ this.inFlight = null;
711
+ clearTimeout(flight.timer);
712
+ if (err) {
713
+ flight.reject(err);
714
+ }
715
+ else {
716
+ flight.resolve(value ?? '');
717
+ }
718
+ };
744
719
  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?`));
720
+ // Destroy the socket on timeout. The bridge serializes commands
721
+ // (peer.handling gate), so a slow command's late response would
722
+ // otherwise correlate against the next command we send. The next
723
+ // sendCommand lazy-reconnects.
724
+ if (this.socket) {
725
+ const sock = this.socket;
726
+ this.socket = null;
727
+ sock.removeAllListeners();
728
+ sock.destroy();
749
729
  }
730
+ this.rxBuffer = Buffer.alloc(0);
731
+ settle(new Error(`Command '${command}' timed out after ${timeoutMs}ms. Is the game running?`));
750
732
  }, timeoutMs);
751
- socket.on('message', (msg) => {
752
- if (!settled) {
753
- settled = true;
754
- clearTimeout(timer);
755
- socket.close();
756
- resolve(msg.toString('utf8'));
733
+ this.inFlight = { command, resolve, reject, timer };
734
+ const ensureSocket = (cb) => {
735
+ if (this.socket) {
736
+ cb();
737
+ return;
757
738
  }
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}`));
739
+ const port = getBridgePort();
740
+ const sock = net.connect(port, '127.0.0.1');
741
+ const onConnect = () => {
742
+ sock.setNoDelay(true);
743
+ sock.removeListener('error', onConnectError);
744
+ this.socket = sock;
745
+ this.rxBuffer = Buffer.alloc(0);
746
+ sock.on('data', (chunk) => {
747
+ this.rxBuffer = Buffer.concat([this.rxBuffer, chunk]);
748
+ try {
749
+ const { frames, remainder } = parseFrames(this.rxBuffer);
750
+ this.rxBuffer = remainder;
751
+ for (const frame of frames) {
752
+ settle(null, frame.toString('utf8'));
753
+ }
754
+ }
755
+ catch (parseErr) {
756
+ const message = parseErr instanceof Error ? parseErr.message : String(parseErr);
757
+ this.socket = null;
758
+ sock.destroy();
759
+ settle(new BridgeDisconnectedError(`Bridge framing error: ${message}`));
760
+ }
761
+ });
762
+ const onClose = () => {
763
+ this.socket = null;
764
+ settle(new BridgeDisconnectedError(`Bridge connection closed before '${command}' response was received`));
765
+ };
766
+ sock.once('close', onClose);
767
+ sock.on('error', (sockErr) => {
768
+ this.socket = null;
769
+ settle(new BridgeDisconnectedError(`Bridge socket error during '${command}': ${sockErr.message}`));
770
+ });
771
+ cb();
772
+ };
773
+ const onConnectError = (connErr) => {
774
+ sock.destroy();
775
+ cb(connErr);
776
+ };
777
+ sock.once('connect', onConnect);
778
+ sock.once('error', onConnectError);
779
+ };
780
+ ensureSocket((err) => {
781
+ if (err) {
782
+ settle(new BridgeDisconnectedError(`Failed to connect to bridge for '${command}': ${err.message}`));
783
+ return;
765
784
  }
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}`));
785
+ if (!this.socket) {
786
+ settle(new BridgeDisconnectedError(`Bridge socket unavailable for '${command}'`));
787
+ return;
788
+ }
789
+ try {
790
+ const payload = JSON.stringify({ command, ...params });
791
+ this.socket.write(encodeFrame(payload));
792
+ }
793
+ catch (writeErr) {
794
+ const message = writeErr instanceof Error ? writeErr.message : String(writeErr);
795
+ settle(new Error(`Failed to send command '${command}': ${message}`));
775
796
  }
776
797
  });
777
798
  });
778
799
  }
800
+ /**
801
+ * Tear down the bridge socket. Idempotent. Any in-flight command is
802
+ * rejected with a session-ended error.
803
+ */
804
+ closeConnection() {
805
+ if (this.inFlight) {
806
+ const flight = this.inFlight;
807
+ this.inFlight = null;
808
+ clearTimeout(flight.timer);
809
+ flight.reject(new BridgeDisconnectedError('Bridge session ended'));
810
+ }
811
+ if (this.socket) {
812
+ const sock = this.socket;
813
+ this.socket = null;
814
+ sock.removeAllListeners();
815
+ sock.destroy();
816
+ }
817
+ this.rxBuffer = Buffer.alloc(0);
818
+ }
779
819
  getErrorCount() {
780
820
  return this.activeProcess?.totalErrorsWritten ?? 0;
781
821
  }
@@ -789,11 +829,9 @@ export class GodotRunner {
789
829
  const window = delta >= errors.length ? errors.slice() : errors.slice(errors.length - delta);
790
830
  return window.filter((line) => line.trim() !== '');
791
831
  }
792
- static SCRIPT_ERROR_PATTERNS = [
793
- 'SCRIPT ERROR:',
794
- 'USER SCRIPT ERROR:',
795
- 'GDScript error',
796
- ];
832
+ // Only the explicit `SCRIPT ERROR:` / `USER SCRIPT ERROR:` markers belong here — the looser
833
+ // `GDScript error` substring also matches user printerr output and produces false positives.
834
+ static SCRIPT_ERROR_PATTERNS = ['SCRIPT ERROR:', 'USER SCRIPT ERROR:'];
797
835
  extractRuntimeErrors(lines) {
798
836
  return lines.filter((line) => GodotRunner.SCRIPT_ERROR_PATTERNS.some((p) => line.includes(p)));
799
837
  }
@@ -804,22 +842,35 @@ export class GodotRunner {
804
842
  const runtimeErrors = this.activeSessionMode === 'spawned' ? this.extractRuntimeErrors(newErrors) : [];
805
843
  return { response, runtimeErrors };
806
844
  }
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;
845
+ /**
846
+ * Shared poll loop for `waitForBridge` (spawned) and `waitForBridgeAttached`.
847
+ * Sends `ping` payloads until the bridge replies with a pong that
848
+ * `validatePong` accepts, the deadline passes, or `shouldAbort` reports
849
+ * the spawned process has exited.
850
+ */
851
+ async pollBridge(opts) {
852
+ const deadline = Date.now() + opts.timeoutMs;
812
853
  while (Date.now() < deadline) {
854
+ if (opts.shouldAbort) {
855
+ const abort = opts.shouldAbort();
856
+ if (abort.aborted) {
857
+ const errorText = abort.tail.length > 0 ? `\nLast stderr:\n${abort.tail.join('\n')}` : '';
858
+ return {
859
+ ready: false,
860
+ error: `Process exited with code ${this.activeProcess?.exitCode ?? '?'} before bridge was ready.${errorText}`,
861
+ };
862
+ }
863
+ }
813
864
  try {
814
- const response = await this.sendCommand('ping', {}, BRIDGE_PING_TIMEOUT_MS);
865
+ const response = await this.sendCommand('ping', opts.pingPayload, BRIDGE_PING_TIMEOUT_MS);
815
866
  const parsed = JSON.parse(response);
816
- if (parsed.status === 'pong') {
817
- if (expectedPath && parsed.project_path) {
867
+ if (opts.validatePong(parsed)) {
868
+ if (opts.expectedPath && parsed.project_path) {
818
869
  const bridgePath = normalizeForCompare(parsed.project_path);
819
- if (bridgePath !== expectedPath) {
870
+ if (bridgePath !== opts.expectedPath) {
820
871
  return {
821
872
  ready: false,
822
- error: `Bridge is running for a different project (${bridgePath}), expected ${expectedPath}`,
873
+ error: `Bridge reports project ${bridgePath}, expected ${opts.expectedPath}`,
823
874
  };
824
875
  }
825
876
  }
@@ -829,56 +880,37 @@ export class GodotRunner {
829
880
  catch {
830
881
  // Expected: ping will fail until bridge is listening
831
882
  }
832
- await new Promise((resolve) => setTimeout(resolve, intervalMs));
883
+ await new Promise((resolve) => setTimeout(resolve, opts.intervalMs));
833
884
  }
834
- return {
835
- ready: false,
836
- error: 'Bridge did not respond within timeout — is Godot running with the McpBridge autoload?',
837
- };
885
+ return { ready: false, error: opts.timeoutError };
886
+ }
887
+ async waitForBridgeAttached(timeoutMs = BRIDGE_WAIT_ATTACHED_TIMEOUT_MS, intervalMs = BRIDGE_WAIT_ATTACHED_INTERVAL_MS) {
888
+ return this.pollBridge({
889
+ expectedPath: this.activeProjectPath ? normalizeForCompare(this.activeProjectPath) : null,
890
+ timeoutMs,
891
+ intervalMs,
892
+ timeoutError: 'Bridge did not respond within timeout — is Godot running with the McpBridge autoload?',
893
+ pingPayload: {},
894
+ validatePong: (parsed) => parsed.status === 'pong',
895
+ });
838
896
  }
839
897
  async waitForBridge(timeoutMs = BRIDGE_WAIT_SPAWNED_TIMEOUT_MS, intervalMs = BRIDGE_WAIT_SPAWNED_INTERVAL_MS) {
840
- const deadline = Date.now() + timeoutMs;
841
898
  const expectedToken = this.activeProcess?.sessionToken;
842
- const expectedPath = this.activeProjectPath
843
- ? normalizeForCompare(this.activeProjectPath)
844
- : null;
845
899
  if (!expectedToken) {
846
900
  return { ready: false, error: 'No active spawned Godot process to verify' };
847
901
  }
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
- };
902
+ return this.pollBridge({
903
+ expectedPath: this.activeProjectPath ? normalizeForCompare(this.activeProjectPath) : null,
904
+ timeoutMs,
905
+ intervalMs,
906
+ timeoutError: 'Bridge did not respond with the expected session token within timeout',
907
+ pingPayload: { session_token: expectedToken },
908
+ validatePong: (parsed) => parsed.status === 'pong' && parsed.session_token === expectedToken,
909
+ shouldAbort: () => ({
910
+ aborted: this.activeProcess !== null && this.activeProcess.hasExited,
911
+ tail: this.getRecentErrors(20),
912
+ }),
913
+ });
882
914
  }
883
915
  getRecentErrors(count = 20) {
884
916
  if (!this.activeProcess)