Files
shawn 91b878f347 Quaternius weapons, input fixes, sound & path fixes
- Added 6 Quaternius weapon models (Pistol, Revolver, Rifle, Shotgun, P90, SniperRifle)
- Created weapon resource files with balanced stats
- Wired Pistol + Rifle into player.tscn replacing Kenney blasters
- Fixed WASD input (matched input map action names)
- Fixed Escape key toggles mouse capture
- Added Audio autoload singleton
- Fixed broken sound asset paths across all scripts
- Fixed all scene ext_resource text paths (nested under assets/kenney-fps/)
- Fixed all .import source_file paths
- Deleted stale .import files (will be regenerated by editor on open)
2026-07-05 22:53:27 -04:00

1119 lines
46 KiB
JavaScript

import { fileURLToPath } from 'url';
import { join, dirname, normalize, resolve, sep } from 'path';
import { existsSync } from 'fs';
import { spawn } from 'child_process';
import * as net from 'net';
import { randomBytes } from 'crypto';
import { BridgeManager } from './bridge-manager.js';
import { DEFAULT_BRIDGE_PORT, encodeFrame, findFreePort, parseFrames, FRAME_HEADER_BYTES, MAX_FRAME_BYTES, } from './bridge-protocol.js';
import { logDebug, logError, DEBUG_MODE } from './logger.js';
/**
* Thrown when the bridge socket closes (Godot exited, port closed, or peer
* dropped the connection mid-flight). Lets callers distinguish
* "session ended" from generic transport errors.
*/
export class BridgeDisconnectedError extends Error {
constructor(message) {
super(message);
this.name = 'BridgeDisconnectedError';
}
}
// Derive __filename and __dirname in ESM
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Bridge readiness polling
export const BRIDGE_WAIT_SPAWNED_TIMEOUT_MS = 8000;
const BRIDGE_WAIT_SPAWNED_INTERVAL_MS = 300;
const BRIDGE_WAIT_ATTACHED_TIMEOUT_MS = 15000;
const BRIDGE_WAIT_ATTACHED_INTERVAL_MS = 500;
const BRIDGE_PING_TIMEOUT_MS = 1000;
const BRIDGE_SHUTDOWN_SPAWNED_TIMEOUT_MS = 500;
const BRIDGE_SHUTDOWN_ATTACHED_TIMEOUT_MS = 1500;
const BRIDGE_PROCESS_EXIT_TIMEOUT_MS = 2000;
const BRIDGE_RECONNECT_DELAY_MS = 1000;
/**
* Normalize a path for cross-platform comparison.
* Folds Windows backslashes to forward slashes and strips trailing slashes,
* so Node's `path.normalize` output matches Godot's `globalize_path("res://")`.
*/
export function normalizeForCompare(p) {
return normalize(p).replace(/\\/g, '/').replace(/\/+$/, '');
}
/**
* Extract JSON from Godot output by finding the first { or [ and matching to the end.
* This strips debug logs, version banners, and other noise.
*/
export function extractJson(output) {
// Find the first occurrence of { or [
const jsonStartBrace = output.indexOf('{');
const jsonStartBracket = output.indexOf('[');
let jsonStart = -1;
if (jsonStartBrace === -1 && jsonStartBracket === -1) {
return output; // No JSON found, return as-is
}
else if (jsonStartBrace === -1) {
jsonStart = jsonStartBracket;
}
else if (jsonStartBracket === -1) {
jsonStart = jsonStartBrace;
}
else {
jsonStart = Math.min(jsonStartBrace, jsonStartBracket);
}
// Extract from JSON start to end
const jsonPart = output.substring(jsonStart);
// Try to parse to validate, if it fails return original
try {
JSON.parse(jsonPart.trim());
return jsonPart.trim();
}
catch {
// If the extracted part isn't valid JSON, try to find the last } or ]
const lastBrace = jsonPart.lastIndexOf('}');
const lastBracket = jsonPart.lastIndexOf(']');
const lastEnd = Math.max(lastBrace, lastBracket);
if (lastEnd > 0) {
const extracted = jsonPart.substring(0, lastEnd + 1);
try {
JSON.parse(extracted);
return extracted;
}
catch {
return output; // Return original if still can't parse
}
}
return output;
}
}
/**
* Strip Godot banner and debug lines from output, keeping only meaningful content.
*/
export function cleanOutput(output) {
const lines = output.split('\n');
const cleanedLines = lines.filter((line) => {
const trimmed = line.trim();
// Skip empty lines
if (!trimmed)
return false;
// Skip Godot version banner
if (trimmed.startsWith('Godot Engine v'))
return false;
// Skip debug lines
if (trimmed.startsWith('[DEBUG]'))
return false;
// Skip info lines that are just status updates
if (trimmed.startsWith('[INFO] Operation:'))
return false;
if (trimmed.startsWith('[INFO] Executing operation:'))
return false;
return true;
});
return cleanedLines.join('\n');
}
export function cleanStdout(stdout) {
if (stdout.includes('{') || stdout.includes('[')) {
return extractJson(stdout);
}
return cleanOutput(stdout);
}
// Parameter mappings between snake_case and camelCase
// Add new entries whenever a tool surfaces a new compound parameter — the
// strict converter throws in test env on unmapped keys to catch oversights.
const parameterMappings = {
project_path: 'projectPath',
scene_path: 'scenePath',
root_node_type: 'rootNodeType',
parent_node_path: 'parentNodePath',
parent_path: 'parentPath',
node_type: 'nodeType',
node_name: 'nodeName',
texture_path: 'texturePath',
node_path: 'nodePath',
node_paths: 'nodePaths',
target_node_path: 'targetNodePath',
target_parent_path: 'targetParentPath',
new_name: 'newName',
output_path: 'outputPath',
mesh_item_names: 'meshItemNames',
new_path: 'newPath',
file_path: 'filePath',
script_path: 'scriptPath',
response_mode: 'responseMode',
preview_max_width: 'previewMaxWidth',
preview_max_height: 'previewMaxHeight',
bridge_port: 'bridgePort',
abort_on_error: 'abortOnError',
max_depth: 'maxDepth',
changed_only: 'changedOnly',
case_sensitive: 'caseSensitive',
file_types: 'fileTypes',
max_results: 'maxResults',
};
// Reverse mapping from camelCase to snake_case
const reverseParameterMappings = {};
for (const [snakeCase, camelCase] of Object.entries(parameterMappings)) {
reverseParameterMappings[camelCase] = snakeCase;
}
export function normalizeParameters(params) {
if (!params || typeof params !== 'object') {
return params;
}
const result = {};
for (const key in params) {
if (Object.prototype.hasOwnProperty.call(params, key)) {
let normalizedKey = key;
if (key.includes('_') && parameterMappings[key]) {
normalizedKey = parameterMappings[key];
}
const value = params[key];
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
result[normalizedKey] = normalizeParameters(value);
}
else {
result[normalizedKey] = value;
}
}
}
return result;
}
function convertCamelToSnakeValue(value) {
if (Array.isArray(value)) {
return value.map((entry) => convertCamelToSnakeValue(entry));
}
if (typeof value === 'object' && value !== null) {
return convertCamelToSnakeCase(value);
}
return value;
}
export function convertCamelToSnakeCase(params) {
const result = {};
const isTestEnv = process.env.NODE_ENV === 'test' || process.env.VITEST === 'true';
for (const key in params) {
if (Object.prototype.hasOwnProperty.call(params, key)) {
const mapped = reverseParameterMappings[key];
let snakeKey;
if (mapped) {
snakeKey = mapped;
}
else if (/[A-Z]/.test(key)) {
// Unmapped camelCase key — tolerated in production via regex fallback,
// but in tests we throw to catch missing entries in parameterMappings.
if (isTestEnv) {
throw new Error(`convertCamelToSnakeCase: unmapped camelCase key '${key}'. ` +
`Add it to parameterMappings in src/utils/godot-runner.ts so snake/camel conversion stays explicit.`);
}
snakeKey = key.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
}
else {
snakeKey = key;
}
result[snakeKey] = convertCamelToSnakeValue(params[key]);
}
}
return result;
}
/**
* Check whether a display server (X11 / Wayland) is available on the current
* platform. On macOS and Windows the display subsystem is always present;
* on Linux we probe the standard environment variables.
*/
export function checkDisplayAvailable() {
if (process.platform !== 'linux')
return true;
return !!(process.env.DISPLAY || process.env.WAYLAND_DISPLAY);
}
export function validatePath(path) {
if (!path || path.includes('..')) {
return false;
}
return true;
}
/**
* Stricter check for paths that must stay inside `projectPath`. Rejects `..`
* (via `validatePath`) and absolute paths that escape the project root.
* `path.join('/project', '/etc/passwd')` resolves to `/etc/passwd`, so the
* basic `..`-substring check alone permits absolute-path traversal.
*
* Tolerates a leading `res://` (Godot's project-root URI) by stripping it
* before resolving — autoload entries and resource paths use this prefix.
*/
export function validateSubPath(projectPath, userPath) {
if (!validatePath(userPath))
return false;
const stripped = userPath.startsWith('res://') ? userPath.slice('res://'.length) : userPath;
if (!stripped)
return false;
const projectRoot = resolve(projectPath);
const resolved = resolve(projectRoot, stripped);
const tail = projectRoot === sep ? sep : projectRoot + sep;
return resolved === projectRoot || resolved.startsWith(tail);
}
/**
* Validate a Godot scene-tree path (NodePath). Scene-tree paths are a
* separate namespace from filesystem paths — they address nodes inside
* a scene, not files on disk, so the project-root containment check
* in `validateSubPath` does not apply.
*
* Rejects empty strings and `..` segments. Accepts both relative
* (`root/Player`) and absolute (`/root/Player`) Godot forms; the
* codebase convention is the relative form.
*/
export function validateNodePath(path) {
return typeof path === 'string' && path.length > 0 && !path.includes('..');
}
/**
* True when `child` resolves to `parent` or a path beneath it. Used by
* defense-in-depth checks on bridge-returned paths (e.g. screenshot files
* that must live under `.mcp/screenshots/`).
*/
export function isUnderDir(parent, child) {
const parentResolved = resolve(parent);
const childResolved = resolve(child);
return childResolved === parentResolved || childResolved.startsWith(parentResolved + sep);
}
/**
* Return `error.message` when `error` is an `Error`, otherwise `'Unknown error'`.
* Centralizes the catch-block boilerplate so handlers can build error responses
* without repeating the `instanceof Error` ternary.
*/
export function getErrorMessage(error) {
return error instanceof Error ? error.message : 'Unknown error';
}
/**
* Build the absolute path to a project's `project.godot` manifest. Use this
* instead of `join(dir, 'project.godot')` ad hoc.
*/
export function projectGodotPath(projectDir) {
return join(projectDir, 'project.godot');
}
/**
* Extract the first [ERROR] message from GDScript stderr output.
* Falls back to a generic message if no [ERROR] line is found.
*/
export function extractGdError(stderr) {
const errLine = stderr.split('\n').find((l) => l.includes('[ERROR]'));
return errLine
? errLine.replace(/.*\[ERROR\]\s*/, '').trim()
: 'see get_debug_output for details';
}
export function createErrorResponse(message, possibleSolutions = []) {
logError(`Error response: ${message}`);
if (possibleSolutions.length > 0) {
logError(`Possible solutions: ${possibleSolutions.join(', ')}`);
}
const response = {
content: [{ type: 'text', text: message }],
isError: true,
};
if (possibleSolutions.length > 0) {
response.content.push({
type: 'text',
text: 'Possible solutions:\n- ' + possibleSolutions.join('\n- '),
});
}
return response;
}
/**
* Wrap a structured payload as a ToolResponse that satisfies the MCP
* outputSchema contract. The same payload is emitted both as a JSON text
* content block (for lenient clients) and as `structuredContent` (required
* by strict clients per MCP spec revision 2025-06-18).
*
* `extraContent` is prepended for handlers that also return non-text blocks
* (e.g. `take_screenshot`'s inline image).
*/
export function createStructuredResponse(payload, extraContent = []) {
return {
content: [...extraContent, { type: 'text', text: JSON.stringify(payload) }],
structuredContent: payload,
};
}
export function validateProjectArgs(args) {
if (!args.projectPath) {
return createErrorResponse('projectPath is required', [
'Provide a valid path to a Godot project directory',
]);
}
if (!validatePath(args.projectPath)) {
return createErrorResponse('Invalid project path', [
'Provide a valid path without ".." or other potentially unsafe characters',
]);
}
const projectFile = projectGodotPath(args.projectPath);
if (!existsSync(projectFile)) {
return createErrorResponse(`Not a valid Godot project: ${args.projectPath}`, [
'Ensure the path points to a directory containing a project.godot file',
]);
}
return { projectPath: args.projectPath };
}
export function validateSceneArgs(args, opts) {
const projectResult = validateProjectArgs(args);
if ('isError' in projectResult)
return projectResult;
const sceneRequired = opts?.sceneRequired !== false;
if (!args.scenePath) {
if (sceneRequired) {
return createErrorResponse('scenePath is required', [
'Provide the scene file path relative to the project',
]);
}
return { projectPath: projectResult.projectPath, scenePath: '' };
}
if (!validateSubPath(projectResult.projectPath, args.scenePath)) {
return createErrorResponse('Invalid scene path', [
'Provide a valid relative path without ".." that stays inside the project directory',
]);
}
if (sceneRequired) {
const sceneFullPath = join(projectResult.projectPath, args.scenePath);
if (!existsSync(sceneFullPath)) {
return createErrorResponse(`Scene file does not exist: ${args.scenePath}`, [
'Ensure the scene path is correct',
'Use create_scene to create a new scene first',
]);
}
}
return { projectPath: projectResult.projectPath, scenePath: args.scenePath };
}
/**
* Read the first `n` bytes from a chunk array without concatenating the
* entire array. If the first chunk already has enough bytes, returns a
* zero-copy subarray; otherwise copies just `n` bytes into a fresh buffer.
* Caller must ensure total length across chunks is >= n.
*/
function readBytesFromChunks(chunks, n) {
if (chunks[0].length >= n)
return chunks[0].subarray(0, n);
const result = Buffer.allocUnsafe(n);
let copied = 0;
for (const c of chunks) {
const take = Math.min(c.length, n - copied);
c.copy(result, copied, 0, take);
copied += take;
if (copied >= n)
break;
}
return result;
}
export class GodotRunner {
godotPath = null;
operationsScriptPath;
bridge;
validatedPaths = new Map();
cachedVersion = null;
activeProcess = null;
activeProjectPath = null;
activeSessionMode = null;
activeBridgePort = null;
socket = null;
// Receive buffer kept as an array of chunks until at least one complete frame
// is available. Avoids re-copying accumulated bytes on every TCP data event
// (the old `Buffer.concat([rxBuffer, chunk])` pattern was O(n²) on large
// frames split across many chunks).
rxChunks = [];
rxTotal = 0;
inFlight = null;
constructor(config) {
this.operationsScriptPath = join(__dirname, '..', 'scripts', 'godot_operations.gd');
const bridgeScriptPath = join(__dirname, '..', 'scripts', 'mcp_bridge.gd');
this.bridge = new BridgeManager(bridgeScriptPath);
logDebug(`Operations script path: ${this.operationsScriptPath}`);
if (config?.godotPath) {
const normalizedPath = normalize(config.godotPath);
if (this.isValidGodotPathSync(normalizedPath)) {
this.godotPath = normalizedPath;
logDebug(`Custom Godot path provided: ${this.godotPath}`);
}
else {
console.warn(`[SERVER] Invalid custom Godot path provided: ${normalizedPath}`);
}
}
}
isValidGodotPathSync(path) {
try {
logDebug(`Quick-validating Godot path: ${path}`);
return path === 'godot' || existsSync(path);
}
catch {
logDebug(`Invalid Godot path: ${path}`);
return false;
}
}
spawnAsync(cmd, args, timeoutMs = 10000) {
return new Promise((resolve, reject) => {
const proc = spawn(cmd, args, { stdio: 'pipe' });
let stdout = '';
let stderr = '';
const timer = setTimeout(() => {
proc.kill();
reject(new Error(`Process timed out after ${timeoutMs}ms`));
}, timeoutMs);
proc.stdout?.on('data', (data) => {
stdout += data.toString();
});
proc.stderr?.on('data', (data) => {
stderr += data.toString();
});
proc.on('error', (err) => {
clearTimeout(timer);
reject(err);
});
proc.on('close', (code) => {
clearTimeout(timer);
if (code === 0) {
resolve({ stdout, stderr });
}
else {
const err = new Error(`Process exited with code ${code}`);
err.stdout = stdout;
err.stderr = stderr;
err.code = code;
reject(err);
}
});
});
}
async isValidGodotPath(path) {
if (this.validatedPaths.has(path)) {
return this.validatedPaths.get(path);
}
try {
logDebug(`Validating Godot path: ${path}`);
if (path !== 'godot' && !existsSync(path)) {
logDebug(`Path does not exist: ${path}`);
this.validatedPaths.set(path, false);
return false;
}
await this.spawnAsync(path, ['--version']);
logDebug(`Valid Godot path: ${path}`);
this.validatedPaths.set(path, true);
return true;
}
catch {
logDebug(`Invalid Godot path: ${path}`);
this.validatedPaths.set(path, false);
return false;
}
}
async detectGodotPath() {
// Explicit paths (constructor config or GODOT_PATH) are authoritative — leave
// godotPath null on failure rather than fabricating a platform default, so
// callers can produce actionable errors.
if (this.godotPath) {
if (await this.isValidGodotPath(this.godotPath)) {
logDebug(`Using existing Godot path: ${this.godotPath}`);
return;
}
logError(`Configured Godot path "${this.godotPath}" is not a working Godot executable. ` +
`Pass a valid Godot 4.x binary via the godotPath config option.`);
this.godotPath = null;
return;
}
if (process.env.GODOT_PATH) {
const normalizedPath = normalize(process.env.GODOT_PATH);
logDebug(`Checking GODOT_PATH environment variable: ${normalizedPath}`);
if (await this.isValidGodotPath(normalizedPath)) {
this.godotPath = normalizedPath;
logDebug(`Using Godot path from environment: ${this.godotPath}`);
return;
}
logError(`GODOT_PATH is set to "${normalizedPath}" but no working Godot executable was found there. ` +
`Update GODOT_PATH to your Godot 4.x binary or unset it to auto-detect.`);
return;
}
const osPlatform = process.platform;
logDebug(`Auto-detecting Godot path for platform: ${osPlatform}`);
const possiblePaths = ['godot'];
if (osPlatform === 'darwin') {
possiblePaths.push('/Applications/Godot.app/Contents/MacOS/Godot', '/Applications/Godot_4.app/Contents/MacOS/Godot', `${process.env.HOME}/Applications/Godot.app/Contents/MacOS/Godot`);
}
else if (osPlatform === 'win32') {
possiblePaths.push('C:\\Program Files\\Godot\\Godot.exe', 'C:\\Program Files (x86)\\Godot\\Godot.exe', `${process.env.USERPROFILE}\\Godot\\Godot.exe`);
}
else if (osPlatform === 'linux') {
possiblePaths.push('/usr/bin/godot', '/usr/local/bin/godot', '/snap/bin/godot', `${process.env.HOME}/.local/bin/godot`);
}
const normalizedCandidates = possiblePaths.map((p) => normalize(p));
const probeResults = await Promise.all(normalizedCandidates.map(async (p) => ({ path: p, valid: await this.isValidGodotPath(p) })));
const winner = probeResults.find((r) => r.valid);
if (winner) {
this.godotPath = winner.path;
logDebug(`Found Godot at: ${winner.path}`);
return;
}
logError(`Could not find Godot in common locations for ${osPlatform}. ` +
`Set GODOT_PATH to your Godot 4.x executable.`);
}
getGodotPath() {
return this.godotPath;
}
/**
* Read the port currently baked into the project's bridge script. Returns
* null if the file is missing or malformed. Thin pass-through to
* BridgeManager — used by bridge-wait-timeout race detection.
*/
readBakedBridgePort(projectPath) {
return this.bridge.readBakedPort(projectPath);
}
async getVersion() {
if (this.cachedVersion !== null) {
return this.cachedVersion;
}
if (!this.godotPath) {
await this.detectGodotPath();
if (!this.godotPath) {
throw new Error('Could not find a valid Godot executable path');
}
}
const { stdout } = await this.spawnAsync(this.godotPath, ['--version']);
this.cachedVersion = stdout.trim();
return this.cachedVersion;
}
async executeOperation(operation, params, projectPath, timeoutMs = 30000) {
logDebug(`Executing operation: ${operation} in project: ${projectPath}`);
logDebug(`Original operation params: ${JSON.stringify(params)}`);
this.bridge.repairOrphaned(projectPath);
const snakeCaseParams = convertCamelToSnakeCase(params);
logDebug(`Converted snake_case params: ${JSON.stringify(snakeCaseParams)}`);
if (!this.godotPath) {
await this.detectGodotPath();
if (!this.godotPath) {
throw new Error('Could not find a valid Godot executable path');
}
}
const paramsJson = JSON.stringify(snakeCaseParams);
const args = [
'--headless',
'--path',
projectPath,
'--script',
this.operationsScriptPath,
operation,
paramsJson,
...(DEBUG_MODE ? ['--debug-godot'] : []),
];
logDebug(`Command: ${this.godotPath} ${args.join(' ')}`);
let stdout = '';
let stderr = '';
try {
({ stdout, stderr } = await this.spawnAsync(this.godotPath, args, timeoutMs));
}
catch (error) {
if (error instanceof Error && 'stdout' in error && 'stderr' in error) {
const execError = error;
stdout = execError.stdout;
stderr = execError.stderr;
}
else {
throw error;
}
}
// If the process produced no operation output but has errors, initialization
// failed before the script ran. Autoload errors are the most common cause.
const operationRan = stdout.trim().length > 0 || stderr.includes('[INFO] Operation:');
if (!operationRan && (stderr.includes('ERROR:') || stderr.includes('SCRIPT ERROR:'))) {
throw new Error(`Headless Godot failed before the operation could run — likely an autoload initialization error.\n` +
`Stderr:\n${stderr.trim()}\n\n` +
`Use list_autoloads and remove_autoload to inspect or remove the failing autoload, then retry.`);
}
return { stdout: cleanStdout(stdout), stderr };
}
launchEditor(projectPath) {
if (!this.godotPath) {
throw new Error('No Godot executable resolved. Set GODOT_PATH to a Godot 4.x binary, or pass godotPath via config.');
}
return spawn(this.godotPath, ['-e', '--path', projectPath], { stdio: 'pipe' });
}
async runProject(projectPath, scene, background = false, bridgePort) {
if (!this.godotPath) {
throw new Error('No Godot executable resolved. Set GODOT_PATH to a Godot 4.x binary, or pass godotPath via config.');
}
if (this.activeSessionMode === 'spawned' && this.activeProcess) {
logDebug('Killing existing Godot process before starting a new one');
this.closeConnection();
this.activeProcess.process.kill();
if (this.activeProjectPath && this.activeProjectPath !== projectPath) {
this.bridge.cleanup(this.activeProjectPath);
}
}
else if (this.activeSessionMode === 'attached' &&
this.activeProjectPath &&
this.activeProjectPath !== projectPath) {
this.closeConnection();
this.bridge.cleanup(this.activeProjectPath);
}
if (!checkDisplayAvailable()) {
throw new Error('No display server available (DISPLAY and WAYLAND_DISPLAY are both unset). ' +
'Godot requires a display to run a project window.');
}
const port = bridgePort ?? (await findFreePort());
this.activeBridgePort = port;
try {
this.bridge.inject(projectPath, port);
}
catch (err) {
logDebug(`Non-fatal: Failed to inject bridge autoload: ${err}`);
}
this.activeProjectPath = projectPath;
this.activeSessionMode = 'spawned';
const cmdArgs = ['--path', projectPath];
if (scene && validateSubPath(projectPath, scene)) {
logDebug(`Adding scene parameter: ${scene}`);
cmdArgs.push(scene);
}
const portSource = bridgePort !== undefined ? 'explicit' : 'auto';
logDebug(`Running Godot project: ${projectPath} (bridge port ${port}, ${portSource})`);
const sessionToken = randomBytes(16).toString('hex');
const spawnOptions = {
stdio: 'pipe',
env: {
...process.env,
MCP_SESSION_TOKEN: sessionToken,
},
};
if (background) {
spawnOptions.env = { ...spawnOptions.env, MCP_BACKGROUND: '1' };
}
const proc = spawn(this.godotPath, cmdArgs, spawnOptions);
const output = [];
const errors = [];
const godotProcess = {
process: proc,
output,
errors,
totalErrorsWritten: 0,
exitCode: null,
hasExited: false,
sessionToken,
};
proc.stdout?.on('data', (data) => {
const lines = data.toString().split('\n');
output.push(...lines);
if (output.length > 500)
output.splice(0, output.length - 500);
lines.forEach((line) => {
if (line.trim())
logDebug(`[Godot stdout] ${line}`);
});
});
proc.stderr?.on('data', (data) => {
const lines = data.toString().split('\n');
godotProcess.totalErrorsWritten += lines.length;
errors.push(...lines);
if (errors.length > 500)
errors.splice(0, errors.length - 500);
lines.forEach((line) => {
if (line.trim())
logDebug(`[Godot stderr] ${line}`);
});
});
proc.on('exit', (code) => {
logDebug(`Godot process exited with code ${code}`);
godotProcess.exitCode = code;
godotProcess.hasExited = true;
// Don't clear activeProcess immediately - keep it so output can be retrieved
});
proc.on('error', (err) => {
console.error('Failed to start Godot process:', err);
errors.push(`Process error: ${err.message}`);
godotProcess.hasExited = true;
});
this.activeProcess = godotProcess;
return this.activeProcess;
}
async attachProject(projectPath, bridgePort) {
if (this.activeSessionMode === 'spawned' && this.activeProcess) {
await this.stopProject();
}
else if (this.activeSessionMode === 'attached' &&
this.activeProjectPath &&
this.activeProjectPath !== projectPath) {
// Different project — detach the old one cleanly so its bridge
// releases the port before we inject into the new project.
try {
await this.sendCommand('shutdown', {}, BRIDGE_SHUTDOWN_ATTACHED_TIMEOUT_MS);
}
catch (err) {
logDebug(`Shutdown command failed during attach swap (ignored): ${err}`);
}
this.closeConnection();
this.bridge.cleanup(this.activeProjectPath);
this.activeProjectPath = null;
this.activeSessionMode = null;
}
const port = bridgePort ?? (await findFreePort());
this.activeBridgePort = port;
this.bridge.inject(projectPath, port);
const portSource = bridgePort !== undefined ? 'explicit' : 'auto';
logDebug(`Attaching to Godot project: ${projectPath} (bridge port ${port}, ${portSource})`);
this.activeProjectPath = projectPath;
this.activeSessionMode = 'attached';
this.activeProcess = null;
}
async stopProject() {
if (!this.activeSessionMode) {
return null;
}
if (this.activeSessionMode === 'attached') {
// Ask the bridge to shut down so the user's still-running Godot
// releases the port. A timeout here is non-fatal — same end state
// as today, the bridge dies when the user closes Godot.
try {
await this.sendCommand('shutdown', {}, BRIDGE_SHUTDOWN_ATTACHED_TIMEOUT_MS);
}
catch (err) {
logDebug(`Attached shutdown timed out or failed (continuing cleanup): ${err}`);
}
this.closeConnection();
const projectPath = this.activeProjectPath;
if (projectPath) {
this.bridge.cleanup(projectPath);
}
this.activeProjectPath = null;
this.activeSessionMode = null;
this.activeBridgePort = null;
this.activeProcess = null;
return {
mode: 'attached',
output: [],
errors: [],
externalProcessPreserved: true,
};
}
if (!this.activeProcess) {
return null;
}
// Spawned: try graceful shutdown so the bridge releases the port,
// then ensure the process actually exits.
try {
await this.sendCommand('shutdown', {}, BRIDGE_SHUTDOWN_SPAWNED_TIMEOUT_MS);
}
catch {
// Bridge may already be unreachable — proceed to kill.
}
this.closeConnection();
logDebug('Stopping active Godot process');
const proc = this.activeProcess.process;
proc.kill();
// Wait up to BRIDGE_PROCESS_EXIT_TIMEOUT_MS for graceful exit; otherwise SIGKILL.
if (!this.activeProcess.hasExited) {
await new Promise((resolve) => {
const timer = setTimeout(() => {
try {
proc.kill('SIGKILL');
}
catch {
// already dead
}
resolve();
}, BRIDGE_PROCESS_EXIT_TIMEOUT_MS);
proc.once('exit', () => {
clearTimeout(timer);
resolve();
});
});
}
const result = {
mode: 'spawned',
output: this.activeProcess.output,
errors: this.activeProcess.errors,
};
this.activeProcess = null;
if (this.activeProjectPath) {
this.bridge.cleanup(this.activeProjectPath);
this.activeProjectPath = null;
}
this.activeSessionMode = null;
this.activeBridgePort = null;
return result;
}
hasActiveRuntimeSession() {
if (!this.activeSessionMode || !this.activeProjectPath) {
return false;
}
if (this.activeSessionMode === 'spawned') {
return this.activeProcess !== null && !this.activeProcess.hasExited;
}
return true;
}
/**
* Send a JSON command to the McpBridge over a long-lived TCP connection.
*
* MCP serializes tool calls so we hold one in-flight command at a time. The
* socket is lazy-connected on first call and persists across commands until
* `closeConnection` (or a peer-side close). A close mid-flight rejects with
* `BridgeDisconnectedError`; a per-command timeout rejects but does NOT
* close the socket — a slow command does not invalidate the session.
*/
sendCommand(command, params = {}, timeoutMs = 10000) {
return new Promise((resolve, reject) => {
if (this.inFlight) {
reject(new Error(`Command '${command}' rejected: another command ('${this.inFlight.command}') is in flight`));
return;
}
const settle = (err, value) => {
if (!this.inFlight)
return;
const flight = this.inFlight;
this.inFlight = null;
clearTimeout(flight.timer);
if (err) {
flight.reject(err);
}
else {
flight.resolve(value ?? '');
}
};
const timer = setTimeout(() => {
// Destroy the socket on timeout. The bridge serializes commands
// (peer.handling gate), so a slow command's late response would
// otherwise correlate against the next command we send. The next
// sendCommand lazy-reconnects.
if (this.socket) {
const sock = this.socket;
this.socket = null;
sock.removeAllListeners();
sock.destroy();
}
this.resetRxBuffer();
settle(new Error(`Command '${command}' timed out after ${timeoutMs}ms. Is the game running?`));
}, timeoutMs);
this.inFlight = { command, resolve, reject, timer };
const ensureSocket = (cb) => {
if (this.socket) {
cb();
return;
}
// Fallback to DEFAULT_BRIDGE_PORT is defensive — every entry point
// (runProject, attachProject) sets activeBridgePort before sendCommand
// can be reached, so this branch is not expected in practice.
const port = this.activeBridgePort ?? DEFAULT_BRIDGE_PORT;
const sock = net.connect(port, '127.0.0.1');
const onConnect = () => {
sock.setNoDelay(true);
sock.removeListener('error', onConnectError);
this.socket = sock;
this.resetRxBuffer();
sock.on('data', (chunk) => {
this.rxChunks.push(chunk);
this.rxTotal += chunk.length;
// Defer the (potentially expensive) concat until we know at least
// one complete frame is ready. Peek the 4-byte header without
// copying all accumulated chunks first.
if (this.rxTotal < FRAME_HEADER_BYTES)
return;
const header = readBytesFromChunks(this.rxChunks, FRAME_HEADER_BYTES);
const firstLen = header.readUInt32BE(0);
if (firstLen > MAX_FRAME_BYTES) {
this.socket = null;
sock.destroy();
settle(new BridgeDisconnectedError(`Bridge frame header advertises ${firstLen} bytes, exceeds limit ${MAX_FRAME_BYTES}`));
return;
}
if (this.rxTotal < FRAME_HEADER_BYTES + firstLen)
return;
try {
const buffer = this.rxChunks.length === 1
? this.rxChunks[0]
: Buffer.concat(this.rxChunks, this.rxTotal);
const { frames, remainder } = parseFrames(buffer);
if (remainder.length === 0) {
this.rxChunks = [];
this.rxTotal = 0;
}
else {
this.rxChunks = [remainder];
this.rxTotal = remainder.length;
}
for (const frame of frames) {
settle(null, frame.toString('utf8'));
}
}
catch (parseErr) {
const message = parseErr instanceof Error ? parseErr.message : String(parseErr);
this.socket = null;
sock.destroy();
settle(new BridgeDisconnectedError(`Bridge framing error: ${message}`));
}
});
const onClose = () => {
this.socket = null;
settle(new BridgeDisconnectedError(`Bridge connection closed before '${command}' response was received`));
};
sock.once('close', onClose);
sock.on('error', (sockErr) => {
this.socket = null;
settle(new BridgeDisconnectedError(`Bridge socket error during '${command}': ${sockErr.message}`));
});
cb();
};
const onConnectError = (connErr) => {
sock.destroy();
cb(connErr);
};
sock.once('connect', onConnect);
sock.once('error', onConnectError);
};
ensureSocket((err) => {
if (err) {
settle(new BridgeDisconnectedError(`Failed to connect to bridge for '${command}': ${err.message}`));
return;
}
if (!this.socket) {
settle(new BridgeDisconnectedError(`Bridge socket unavailable for '${command}'`));
return;
}
try {
const payload = JSON.stringify({ command, ...params });
this.socket.write(encodeFrame(payload));
}
catch (writeErr) {
const message = writeErr instanceof Error ? writeErr.message : String(writeErr);
settle(new Error(`Failed to send command '${command}': ${message}`));
}
});
});
}
/**
* Tear down the bridge socket. Idempotent. Any in-flight command is
* rejected with a session-ended error.
*/
closeConnection() {
if (this.inFlight) {
const flight = this.inFlight;
this.inFlight = null;
clearTimeout(flight.timer);
flight.reject(new BridgeDisconnectedError('Bridge session ended'));
}
if (this.socket) {
const sock = this.socket;
this.socket = null;
sock.removeAllListeners();
sock.destroy();
}
this.resetRxBuffer();
}
resetRxBuffer() {
this.rxChunks = [];
this.rxTotal = 0;
}
getErrorCount() {
return this.activeProcess?.totalErrorsWritten ?? 0;
}
getErrorsSince(marker) {
if (!this.activeProcess)
return [];
const { errors, totalErrorsWritten } = this.activeProcess;
const delta = totalErrorsWritten - marker;
if (delta <= 0)
return [];
const window = delta >= errors.length ? errors.slice() : errors.slice(errors.length - delta);
return window.filter((line) => line.trim() !== '');
}
// Only the explicit `SCRIPT ERROR:` / `USER SCRIPT ERROR:` markers belong here — the looser
// `GDScript error` substring also matches user printerr output and produces false positives.
static SCRIPT_ERROR_PATTERNS = ['SCRIPT ERROR:', 'USER SCRIPT ERROR:'];
static RETRYABLE_BRIDGE_COMMANDS = new Set(['get_ui_elements', 'screenshot']);
extractRuntimeErrors(lines) {
return lines.filter((line) => GodotRunner.SCRIPT_ERROR_PATTERNS.some((p) => line.includes(p)));
}
async sendCommandWithReconnect(command, params = {}, timeoutMs = 10000) {
try {
return await this.sendCommand(command, params, timeoutMs);
}
catch (err) {
if (err instanceof BridgeDisconnectedError &&
this.activeSessionMode &&
GodotRunner.RETRYABLE_BRIDGE_COMMANDS.has(command)) {
this.closeConnection();
await new Promise((r) => setTimeout(r, BRIDGE_RECONNECT_DELAY_MS));
return this.sendCommand(command, params, timeoutMs);
}
throw err;
}
}
async sendCommandWithErrors(command, params = {}, timeoutMs = 10000) {
const marker = this.getErrorCount();
const response = await this.sendCommandWithReconnect(command, params, timeoutMs);
const newErrors = this.getErrorsSince(marker);
const runtimeErrors = this.activeSessionMode === 'spawned' ? this.extractRuntimeErrors(newErrors) : [];
return { response, runtimeErrors };
}
/**
* Shared poll loop for `waitForBridge` (spawned) and `waitForBridgeAttached`.
* Sends `ping` payloads until the bridge replies with a pong that
* `validatePong` accepts, the deadline passes, or `shouldAbort` reports
* the spawned process has exited.
*/
async pollBridge(opts) {
const deadline = Date.now() + opts.timeoutMs;
while (Date.now() < deadline) {
if (opts.shouldAbort) {
const abort = opts.shouldAbort();
if (abort.aborted) {
const errorText = abort.tail.length > 0 ? `\nLast stderr:\n${abort.tail.join('\n')}` : '';
return {
ready: false,
error: `Process exited with code ${this.activeProcess?.exitCode ?? '?'} before bridge was ready.${errorText}`,
};
}
}
try {
const response = await this.sendCommand('ping', opts.pingPayload, BRIDGE_PING_TIMEOUT_MS);
const parsed = JSON.parse(response);
if (opts.validatePong(parsed)) {
if (opts.expectedPath && parsed.project_path) {
const bridgePath = normalizeForCompare(parsed.project_path);
if (bridgePath !== opts.expectedPath) {
return {
ready: false,
error: `Bridge reports project ${bridgePath}, expected ${opts.expectedPath}`,
};
}
}
return { ready: true };
}
}
catch {
// Expected: ping will fail until bridge is listening
}
await new Promise((resolve) => setTimeout(resolve, opts.intervalMs));
}
return { ready: false, error: opts.timeoutError };
}
async waitForBridgeAttached(timeoutMs = BRIDGE_WAIT_ATTACHED_TIMEOUT_MS, intervalMs = BRIDGE_WAIT_ATTACHED_INTERVAL_MS) {
return this.pollBridge({
expectedPath: this.activeProjectPath ? normalizeForCompare(this.activeProjectPath) : null,
timeoutMs,
intervalMs,
timeoutError: 'Bridge did not respond within timeout — is Godot running with the McpBridge autoload?',
pingPayload: {},
validatePong: (parsed) => parsed.status === 'pong',
});
}
async waitForBridge(timeoutMs = BRIDGE_WAIT_SPAWNED_TIMEOUT_MS, intervalMs = BRIDGE_WAIT_SPAWNED_INTERVAL_MS) {
const expectedToken = this.activeProcess?.sessionToken;
if (!expectedToken) {
return { ready: false, error: 'No active spawned Godot process to verify' };
}
return this.pollBridge({
expectedPath: this.activeProjectPath ? normalizeForCompare(this.activeProjectPath) : null,
timeoutMs,
intervalMs,
timeoutError: 'Bridge did not respond with the expected session token within timeout',
pingPayload: { session_token: expectedToken },
validatePong: (parsed) => parsed.status === 'pong' && parsed.session_token === expectedToken,
shouldAbort: () => ({
aborted: this.activeProcess !== null && this.activeProcess.hasExited,
tail: this.getRecentErrors(20),
}),
});
}
getRecentErrors(count = 20) {
if (!this.activeProcess)
return [];
return this.activeProcess.errors.slice(-count).filter((line) => line.trim() !== '');
}
}
//# sourceMappingURL=godot-runner.js.map