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.
- package/README.md +30 -93
- package/dist/dispatch.d.ts +1 -11
- package/dist/dispatch.d.ts.map +1 -1
- package/dist/dispatch.js +7 -8
- 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 +134 -283
- package/dist/scripts/mcp_bridge.gd +210 -43
- 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 +187 -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 +114 -310
- 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 +120 -1192
- 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 +808 -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 +67 -211
- 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 +35 -29
- package/dist/tools/validate-tools.js.map +1 -1
- package/dist/utils/autoload-ini.d.ts +32 -0
- package/dist/utils/autoload-ini.d.ts.map +1 -0
- package/dist/utils/autoload-ini.js +111 -0
- package/dist/utils/autoload-ini.js.map +1 -0
- package/dist/utils/bridge-manager.d.ts +29 -0
- package/dist/utils/bridge-manager.d.ts.map +1 -0
- package/dist/utils/bridge-manager.js +136 -0
- package/dist/utils/bridge-manager.js.map +1 -0
- package/dist/utils/bridge-protocol.d.ts +34 -0
- package/dist/utils/bridge-protocol.d.ts.map +1 -0
- package/dist/utils/bridge-protocol.js +65 -0
- package/dist/utils/bridge-protocol.js.map +1 -0
- package/dist/utils/godot-runner.d.ts +70 -15
- package/dist/utils/godot-runner.d.ts.map +1 -1
- package/dist/utils/godot-runner.js +309 -277
- 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 +3 -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 +7 -4
|
@@ -1,20 +1,36 @@
|
|
|
1
1
|
import { fileURLToPath } from 'url';
|
|
2
2
|
import { join, dirname, normalize } from 'path';
|
|
3
|
-
import { existsSync
|
|
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 { 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 =
|
|
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
|
-
|
|
281
|
+
bridge;
|
|
250
282
|
validatedPaths = new Map();
|
|
251
|
-
|
|
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
|
-
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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 (
|
|
375
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
506
|
+
this.closeConnection();
|
|
507
|
+
this.bridge.cleanup(this.activeProjectPath);
|
|
488
508
|
}
|
|
489
509
|
try {
|
|
490
|
-
this.
|
|
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
|
-
|
|
529
|
-
|
|
530
|
-
|
|
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
|
-
|
|
540
|
-
|
|
541
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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
|
-
*
|
|
661
|
-
*
|
|
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
|
-
|
|
743
|
-
|
|
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
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
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
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
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
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
socket
|
|
764
|
-
|
|
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
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
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
|
-
|
|
793
|
-
|
|
794
|
-
|
|
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
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
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',
|
|
865
|
+
const response = await this.sendCommand('ping', opts.pingPayload, BRIDGE_PING_TIMEOUT_MS);
|
|
815
866
|
const parsed = JSON.parse(response);
|
|
816
|
-
if (parsed
|
|
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
|
|
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
|
-
|
|
836
|
-
|
|
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
|
-
|
|
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
|
-
};
|
|
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)
|