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)
This commit is contained in:
Vendored
+16
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Tool dispatch table.
|
||||
*
|
||||
* Maps every MCP tool name to a handler that takes the runner + raw args and
|
||||
* returns the tool response. Extracted from index.ts so tests can exercise
|
||||
* dispatch as a pure data structure (no Server / stdio / lifecycle setup).
|
||||
*
|
||||
* Behavioral contract preserved from the original switch in index.ts:
|
||||
* - Each name routes to the same handler it did before.
|
||||
* - Unknown tool names throw McpError(MethodNotFound, ...) — see
|
||||
* `dispatchToolCall`.
|
||||
*/
|
||||
import type { GodotRunner, OperationParams, ToolHandler, ToolResponse } from './utils/godot-runner.js';
|
||||
export declare const toolDispatch: Record<string, ToolHandler>;
|
||||
export declare function dispatchToolCall(runner: GodotRunner, toolName: string, args: OperationParams): Promise<ToolResponse>;
|
||||
//# sourceMappingURL=dispatch.d.ts.map
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"dispatch.d.ts","sourceRoot":"","sources":["../src/dispatch.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAIH,OAAO,KAAK,EACV,WAAW,EACX,eAAe,EACf,WAAW,EACX,YAAY,EACb,MAAM,yBAAyB,CAAC;AAsDjC,eAAO,MAAM,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,WAAW,CA4CpD,CAAC;AAEF,wBAAsB,gBAAgB,CACpC,MAAM,EAAE,WAAW,EACnB,QAAQ,EAAE,MAAM,EAChB,IAAI,EAAE,eAAe,GACpB,OAAO,CAAC,YAAY,CAAC,CAMvB"}
|
||||
Vendored
+69
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Tool dispatch table.
|
||||
*
|
||||
* Maps every MCP tool name to a handler that takes the runner + raw args and
|
||||
* returns the tool response. Extracted from index.ts so tests can exercise
|
||||
* dispatch as a pure data structure (no Server / stdio / lifecycle setup).
|
||||
*
|
||||
* Behavioral contract preserved from the original switch in index.ts:
|
||||
* - Each name routes to the same handler it did before.
|
||||
* - Unknown tool names throw McpError(MethodNotFound, ...) — see
|
||||
* `dispatchToolCall`.
|
||||
*/
|
||||
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { handleLaunchEditor, handleRunProject, handleAttachProject, handleDetachProject, handleGetDebugOutput, handleStopProject, handleTakeScreenshot, handleSimulateInput, handleGetUiElements, handleRunScript, } from './tools/runtime-tools.js';
|
||||
import { handleListAutoloads, handleAddAutoload, handleRemoveAutoload, handleUpdateAutoload, } from './tools/autoload-tools.js';
|
||||
import { handleListProjects, handleGetProjectInfo, handleGetProjectFiles, handleSearchProject, handleGetSceneDependencies, handleGetProjectSettings, } from './tools/project-tools.js';
|
||||
import { handleCreateScene, handleAddNode, handleLoadSprite, handleSaveScene, handleExportMeshLibrary, handleBatchSceneOperations, } from './tools/scene-tools.js';
|
||||
import { handleDeleteNodes, handleSetNodeProperties, handleGetNodeProperties, handleAttachScript, handleGetSceneTree, handleDuplicateNode, handleGetNodeSignals, handleConnectSignal, handleDisconnectSignal, } from './tools/node-tools.js';
|
||||
import { handleValidate } from './tools/validate-tools.js';
|
||||
export const toolDispatch = {
|
||||
// Project tools
|
||||
launch_editor: handleLaunchEditor,
|
||||
run_project: handleRunProject,
|
||||
attach_project: handleAttachProject,
|
||||
detach_project: handleDetachProject,
|
||||
get_debug_output: handleGetDebugOutput,
|
||||
stop_project: handleStopProject,
|
||||
list_projects: (_runner, args) => handleListProjects(args),
|
||||
get_project_info: handleGetProjectInfo,
|
||||
take_screenshot: handleTakeScreenshot,
|
||||
simulate_input: handleSimulateInput,
|
||||
get_ui_elements: handleGetUiElements,
|
||||
run_script: handleRunScript,
|
||||
list_autoloads: (_runner, args) => handleListAutoloads(args),
|
||||
add_autoload: (_runner, args) => handleAddAutoload(args),
|
||||
remove_autoload: (_runner, args) => handleRemoveAutoload(args),
|
||||
update_autoload: (_runner, args) => handleUpdateAutoload(args),
|
||||
get_project_files: (_runner, args) => handleGetProjectFiles(args),
|
||||
search_project: (_runner, args) => handleSearchProject(args),
|
||||
get_scene_dependencies: (_runner, args) => handleGetSceneDependencies(args),
|
||||
get_project_settings: (_runner, args) => handleGetProjectSettings(args),
|
||||
// Scene tools
|
||||
create_scene: handleCreateScene,
|
||||
add_node: handleAddNode,
|
||||
load_sprite: handleLoadSprite,
|
||||
save_scene: handleSaveScene,
|
||||
export_mesh_library: handleExportMeshLibrary,
|
||||
batch_scene_operations: handleBatchSceneOperations,
|
||||
// Node tools
|
||||
delete_nodes: handleDeleteNodes,
|
||||
set_node_properties: handleSetNodeProperties,
|
||||
get_node_properties: handleGetNodeProperties,
|
||||
attach_script: handleAttachScript,
|
||||
get_scene_tree: handleGetSceneTree,
|
||||
duplicate_node: handleDuplicateNode,
|
||||
get_node_signals: handleGetNodeSignals,
|
||||
connect_signal: handleConnectSignal,
|
||||
disconnect_signal: handleDisconnectSignal,
|
||||
// Validate tools
|
||||
validate: handleValidate,
|
||||
};
|
||||
export async function dispatchToolCall(runner, toolName, args) {
|
||||
const handler = toolDispatch[toolName];
|
||||
if (!handler) {
|
||||
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${toolName}`);
|
||||
}
|
||||
return await handler(runner, args);
|
||||
}
|
||||
//# sourceMappingURL=dispatch.js.map
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"dispatch.js","sourceRoot":"","sources":["../src/dispatch.ts"],"names":[],"mappings":"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"}
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Godot MCP Server
|
||||
*
|
||||
* This MCP server provides tools for interacting with the Godot game engine.
|
||||
* It enables AI assistants to launch the Godot editor, run Godot projects,
|
||||
* capture debug output, manipulate scenes and nodes, and more.
|
||||
*/
|
||||
export declare const allToolDefinitions: import("./utils/godot-runner.js").ToolDefinition[];
|
||||
export declare const serverInstructions = "Godot MCP Server \u2014 AI-driven Godot 4.x project manipulation.\n\nTool categories:\n- Project management: launch_editor, run_project, attach_project, detach_project, stop_project, get_debug_output, list_projects, get_project_info\n- Scene editing (headless): create_scene, add_node, load_sprite, save_scene, export_mesh_library, batch_scene_operations\n- Node editing (headless): delete_nodes, set_node_properties, get_node_properties, attach_script, get_scene_tree, duplicate_node, get_node_signals, connect_signal, disconnect_signal\n- Runtime (requires run_project or attach_project): take_screenshot, simulate_input, get_ui_elements, run_script\n- Project config (no Godot process): list_autoloads, add_autoload, remove_autoload, update_autoload, get_project_files, search_project, get_scene_dependencies, get_project_settings\n- Validation: validate\n\nKey behaviors:\n- All mutation operations (add_node, set_node_properties, delete_nodes, etc.) save the scene automatically. Only use save_scene for save-as (newPath) or re-canonicalization.\n- Headless Godot initializes ALL registered autoloads. If any autoload is broken, headless operations will fail. Use list_autoloads / remove_autoload to diagnose.\n- run_project verifies bridge readiness before returning success. If it reports degraded status, retry runtime tools after a moment or check get_debug_output.\n- attach_project is the fallback path for a manually launched Godot process. It injects the bridge and marks the project active, but it does not spawn Godot or capture stdout/stderr.\n- click_element in simulate_input resolves by node path or node name (BFS search), NOT by visible text. Use get_ui_elements to discover valid element identifiers.\n- run_script expects GDScript with \"extends RefCounted\" and \"func execute(scene_tree: SceneTree) -> Variant\".\n- run_project spawns Godot without -d so runtime errors do not pause execution; the `breakpoint` keyword in user code is a no-op (no debugger is attached). SCRIPT ERROR output and GDScript backtraces still appear in stderr.";
|
||||
//# sourceMappingURL=index.d.ts.map
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AACA;;;;;;GAMG;AAkBH,eAAO,MAAM,kBAAkB,oDAO9B,CAAC;AAEF,eAAO,MAAM,kBAAkB,mhEAiBmM,CAAC"}
|
||||
Vendored
+114
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Godot MCP Server
|
||||
*
|
||||
* This MCP server provides tools for interacting with the Godot game engine.
|
||||
* It enables AI assistants to launch the Godot editor, run Godot projects,
|
||||
* capture debug output, manipulate scenes and nodes, and more.
|
||||
*/
|
||||
// Lower-level `Server` is deliberate; see CONTRIBUTING.md "MCP SDK: Server vs McpServer".
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { GodotRunner, getErrorMessage } from './utils/godot-runner.js';
|
||||
import { dispatchToolCall } from './dispatch.js';
|
||||
import { runtimeToolDefinitions } from './tools/runtime-tools.js';
|
||||
import { autoloadToolDefinitions } from './tools/autoload-tools.js';
|
||||
import { projectToolDefinitions } from './tools/project-tools.js';
|
||||
import { sceneToolDefinitions } from './tools/scene-tools.js';
|
||||
import { nodeToolDefinitions } from './tools/node-tools.js';
|
||||
import { validateToolDefinitions } from './tools/validate-tools.js';
|
||||
export const allToolDefinitions = [
|
||||
...runtimeToolDefinitions,
|
||||
...autoloadToolDefinitions,
|
||||
...projectToolDefinitions,
|
||||
...sceneToolDefinitions,
|
||||
...nodeToolDefinitions,
|
||||
...validateToolDefinitions,
|
||||
];
|
||||
export const serverInstructions = `Godot MCP Server — AI-driven Godot 4.x project manipulation.
|
||||
|
||||
Tool categories:
|
||||
- Project management: launch_editor, run_project, attach_project, detach_project, stop_project, get_debug_output, list_projects, get_project_info
|
||||
- Scene editing (headless): create_scene, add_node, load_sprite, save_scene, export_mesh_library, batch_scene_operations
|
||||
- Node editing (headless): delete_nodes, set_node_properties, get_node_properties, attach_script, get_scene_tree, duplicate_node, get_node_signals, connect_signal, disconnect_signal
|
||||
- Runtime (requires run_project or attach_project): take_screenshot, simulate_input, get_ui_elements, run_script
|
||||
- Project config (no Godot process): list_autoloads, add_autoload, remove_autoload, update_autoload, get_project_files, search_project, get_scene_dependencies, get_project_settings
|
||||
- Validation: validate
|
||||
|
||||
Key behaviors:
|
||||
- All mutation operations (add_node, set_node_properties, delete_nodes, etc.) save the scene automatically. Only use save_scene for save-as (newPath) or re-canonicalization.
|
||||
- Headless Godot initializes ALL registered autoloads. If any autoload is broken, headless operations will fail. Use list_autoloads / remove_autoload to diagnose.
|
||||
- run_project verifies bridge readiness before returning success. If it reports degraded status, retry runtime tools after a moment or check get_debug_output.
|
||||
- attach_project is the fallback path for a manually launched Godot process. It injects the bridge and marks the project active, but it does not spawn Godot or capture stdout/stderr.
|
||||
- click_element in simulate_input resolves by node path or node name (BFS search), NOT by visible text. Use get_ui_elements to discover valid element identifiers.
|
||||
- run_script expects GDScript with "extends RefCounted" and "func execute(scene_tree: SceneTree) -> Variant".
|
||||
- run_project spawns Godot without -d so runtime errors do not pause execution; the \`breakpoint\` keyword in user code is a no-op (no debugger is attached). SCRIPT ERROR output and GDScript backtraces still appear in stderr.`;
|
||||
class GodotMcpServer {
|
||||
server;
|
||||
runner;
|
||||
constructor(config) {
|
||||
this.runner = new GodotRunner(config);
|
||||
this.server = new Server({
|
||||
name: 'godot-mcp',
|
||||
version: '3.1.2',
|
||||
}, {
|
||||
capabilities: {
|
||||
tools: {},
|
||||
},
|
||||
instructions: serverInstructions,
|
||||
});
|
||||
this.setupToolHandlers();
|
||||
this.server.onerror = (error) => console.error('[MCP Error]', error);
|
||||
process.on('SIGINT', async () => {
|
||||
await this.cleanup();
|
||||
process.exit(0);
|
||||
});
|
||||
process.on('SIGTERM', async () => {
|
||||
await this.cleanup();
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
async cleanup() {
|
||||
console.error('[SERVER] Cleaning up resources');
|
||||
await this.runner.stopProject();
|
||||
await this.server.close();
|
||||
}
|
||||
setupToolHandlers() {
|
||||
this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
||||
tools: allToolDefinitions,
|
||||
}));
|
||||
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const toolName = request.params.name;
|
||||
const args = request.params.arguments || {};
|
||||
console.error(`[SERVER] Handling tool request: ${toolName}`);
|
||||
return await dispatchToolCall(this.runner, toolName, args);
|
||||
});
|
||||
}
|
||||
async run() {
|
||||
try {
|
||||
await this.runner.detectGodotPath();
|
||||
const godotPath = this.runner.getGodotPath();
|
||||
if (godotPath) {
|
||||
console.error(`[SERVER] Using Godot at: ${godotPath}`);
|
||||
}
|
||||
// detectGodotPath() already emits a specific logError on failure (bad
|
||||
// GODOT_PATH, no binary found, etc.). Don't duplicate with a generic
|
||||
// warning here — the runner's message names the actual cause.
|
||||
const transport = new StdioServerTransport();
|
||||
await this.server.connect(transport);
|
||||
console.error('Godot MCP server running on stdio');
|
||||
}
|
||||
catch (error) {
|
||||
console.error('[SERVER] Failed to start:', getErrorMessage(error));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Create and run the server
|
||||
const server = new GodotMcpServer();
|
||||
server.run().catch((error) => {
|
||||
console.error('Failed to run server:', getErrorMessage(error));
|
||||
process.exit(1);
|
||||
});
|
||||
//# sourceMappingURL=index.js.map
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
||||
+1026
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
uid://b1jod8iwdknaw
|
||||
Vendored
+635
@@ -0,0 +1,635 @@
|
||||
extends Node
|
||||
|
||||
# KEEP IN SYNC: src/utils/bridge-protocol.ts implements the same framing on the
|
||||
# Node side. Any change here MUST be mirrored there (and vice versa).
|
||||
#
|
||||
# Wire format: 4-byte big-endian length prefix + UTF-8 JSON payload.
|
||||
# Max frame size 16 MiB; oversize frames close the offending peer.
|
||||
|
||||
# Port is baked into this script at inject time by BridgeManager.inject — the
|
||||
# integer literal below is rewritten in the project copy. The 9900 here is the
|
||||
# source-of-truth default that ships with the script so it remains runnable
|
||||
# standalone (e.g. validate, manual debugging).
|
||||
const PORT := 9900 # MCP_BRIDGE_PORT_BAKED
|
||||
const MAX_FRAME_BYTES := 16 * 1024 * 1024
|
||||
const FRAME_HEADER_BYTES := 4
|
||||
|
||||
class PeerState:
|
||||
extends RefCounted
|
||||
var stream: StreamPeerTCP
|
||||
var buffer: PackedByteArray = PackedByteArray()
|
||||
var expected_len: int = -1 # -1 = waiting on header
|
||||
var handling: bool = false # true while a command is awaiting a response
|
||||
|
||||
var tcp_server: TCPServer
|
||||
var session_token: String = ""
|
||||
var _peers: Array = [] # Array[PeerState]
|
||||
var _shutting_down: bool = false # One-shot: set true in shutdown(); never reset (autoload is recreated on next session)
|
||||
|
||||
func _ready() -> void:
|
||||
process_mode = Node.PROCESS_MODE_ALWAYS
|
||||
session_token = OS.get_environment("MCP_SESSION_TOKEN")
|
||||
tcp_server = TCPServer.new()
|
||||
var err = tcp_server.listen(PORT, "127.0.0.1")
|
||||
if err != OK:
|
||||
push_error("McpBridge: Failed to listen on port %d (error %d)" % [PORT, err])
|
||||
else:
|
||||
print("McpBridge: Listening on TCP port %d" % PORT)
|
||||
|
||||
if OS.get_environment("MCP_BACKGROUND") == "1":
|
||||
DisplayServer.window_set_flag(DisplayServer.WINDOW_FLAG_NO_FOCUS, true)
|
||||
DisplayServer.window_set_flag(DisplayServer.WINDOW_FLAG_MOUSE_PASSTHROUGH, true)
|
||||
DisplayServer.window_set_flag(DisplayServer.WINDOW_FLAG_BORDERLESS, true)
|
||||
DisplayServer.window_set_position(Vector2i(-9999, -9999))
|
||||
print("McpBridge: Background mode active - window hidden, physical input blocked")
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if tcp_server == null or not tcp_server.is_listening():
|
||||
return
|
||||
|
||||
while tcp_server.is_connection_available():
|
||||
var stream := tcp_server.take_connection()
|
||||
if stream == null:
|
||||
break
|
||||
stream.set_no_delay(true)
|
||||
var peer := PeerState.new()
|
||||
peer.stream = stream
|
||||
_peers.append(peer)
|
||||
|
||||
# Backwards iteration so remove_at() doesn't shift entries we haven't seen
|
||||
# yet, and avoids the O(n) cost of Array.erase() per removal.
|
||||
var i := _peers.size()
|
||||
while i > 0:
|
||||
i -= 1
|
||||
var peer = _peers[i]
|
||||
_poll_peer(peer)
|
||||
if peer.stream == null or peer.stream.get_status() != StreamPeerTCP.STATUS_CONNECTED:
|
||||
_peers.remove_at(i)
|
||||
|
||||
func _poll_peer(peer: PeerState) -> void:
|
||||
peer.stream.poll()
|
||||
var status := peer.stream.get_status()
|
||||
if status != StreamPeerTCP.STATUS_CONNECTED:
|
||||
return
|
||||
|
||||
var available := peer.stream.get_available_bytes()
|
||||
if available > 0:
|
||||
var chunk: Array = peer.stream.get_partial_data(available)
|
||||
# get_partial_data returns [error, PackedByteArray]
|
||||
if chunk[0] == OK:
|
||||
peer.buffer.append_array(chunk[1])
|
||||
|
||||
while true:
|
||||
if peer.expected_len < 0:
|
||||
if peer.buffer.size() < FRAME_HEADER_BYTES:
|
||||
return
|
||||
# Read u32 BE header.
|
||||
var header := peer.buffer.slice(0, FRAME_HEADER_BYTES)
|
||||
var b0 := int(header[0])
|
||||
var b1 := int(header[1])
|
||||
var b2 := int(header[2])
|
||||
var b3 := int(header[3])
|
||||
peer.expected_len = (b0 << 24) | (b1 << 16) | (b2 << 8) | b3
|
||||
peer.buffer = peer.buffer.slice(FRAME_HEADER_BYTES)
|
||||
if peer.expected_len > MAX_FRAME_BYTES:
|
||||
push_error("McpBridge: Frame header exceeds limit (%d), closing peer" % peer.expected_len)
|
||||
peer.stream.disconnect_from_host()
|
||||
peer.stream = null
|
||||
return
|
||||
|
||||
if peer.handling:
|
||||
return
|
||||
if peer.buffer.size() < peer.expected_len:
|
||||
return
|
||||
|
||||
var frame_bytes := peer.buffer.slice(0, peer.expected_len)
|
||||
peer.buffer = peer.buffer.slice(peer.expected_len)
|
||||
peer.expected_len = -1
|
||||
|
||||
var data := frame_bytes.get_string_from_utf8().strip_edges()
|
||||
peer.handling = true
|
||||
_dispatch_command(peer, data)
|
||||
# _dispatch_command awaits internally on async branches (input, run_script,
|
||||
# screenshot, shutdown), so control returns here at the first inner await.
|
||||
# `peer.handling` is the gate that blocks re-entry; it is cleared by
|
||||
# `_send_response` once the handler completes.
|
||||
|
||||
# INVARIANT: every code path through this function and its handlers must
|
||||
# eventually reach `_send_response`. `peer.handling` is set to true by the
|
||||
# caller (`_poll_peer`) before dispatch and cleared inside `_send_response`.
|
||||
# A handler that exits without calling `_send_response` will deadlock the
|
||||
# peer — the next frame will never be polled. When adding a new branch,
|
||||
# ensure the early-exit calls `_send_response` with an error payload.
|
||||
func _dispatch_command(peer: PeerState, data: String) -> void:
|
||||
if not data.begins_with("{"):
|
||||
_send_response(peer, {"error": "Non-JSON frame (expected a JSON command object)"})
|
||||
return
|
||||
|
||||
var json = JSON.new()
|
||||
var err = json.parse(data)
|
||||
if err != OK:
|
||||
_send_response(peer, {"error": "Invalid JSON: %s" % json.get_error_message()})
|
||||
return
|
||||
|
||||
var payload = json.data
|
||||
if typeof(payload) != TYPE_DICTIONARY:
|
||||
_send_response(peer, {"error": "Expected JSON object"})
|
||||
return
|
||||
|
||||
var command = payload.get("command", "")
|
||||
match command:
|
||||
"input":
|
||||
var actions = payload.get("actions", [])
|
||||
if typeof(actions) != TYPE_ARRAY:
|
||||
_send_response(peer, {"error": "actions must be an array"})
|
||||
return
|
||||
if actions.is_empty():
|
||||
_send_response(peer, {"error": "actions array is empty"})
|
||||
return
|
||||
await _handle_input(peer, actions)
|
||||
"get_ui_elements":
|
||||
_handle_get_ui_elements(peer, payload)
|
||||
"run_script":
|
||||
await _handle_run_script(peer, payload)
|
||||
"screenshot":
|
||||
await _handle_screenshot(peer, payload)
|
||||
"shutdown":
|
||||
await _handle_shutdown(peer)
|
||||
"ping":
|
||||
_send_response(peer, {"status": "pong", "session_token": session_token, "project_path": ProjectSettings.globalize_path("res://")})
|
||||
_:
|
||||
_send_response(peer, {"error": "Unknown command: %s" % command})
|
||||
|
||||
# --- Screenshot ---
|
||||
|
||||
func _handle_screenshot(peer: PeerState, payload: Dictionary = {}) -> void:
|
||||
await RenderingServer.frame_post_draw
|
||||
|
||||
var viewport := get_viewport()
|
||||
if viewport == null:
|
||||
_send_response(peer, {"error": "No viewport available"})
|
||||
return
|
||||
|
||||
var image := viewport.get_texture().get_image()
|
||||
if image == null:
|
||||
_send_response(peer, {"error": "Failed to capture viewport image"})
|
||||
return
|
||||
|
||||
var timestamp := str(Time.get_unix_time_from_system()).replace(".", "_")
|
||||
var screenshot_dir := ProjectSettings.globalize_path("res://.mcp/screenshots")
|
||||
DirAccess.make_dir_recursive_absolute(screenshot_dir)
|
||||
var file_path := screenshot_dir.path_join("screenshot_%s.png" % timestamp)
|
||||
|
||||
var save_err := image.save_png(file_path)
|
||||
if save_err != OK:
|
||||
_send_response(peer, {"error": "Failed to save screenshot (error %d)" % save_err})
|
||||
return
|
||||
|
||||
var safe_path := file_path.replace("\\", "/")
|
||||
var response: Dictionary = {
|
||||
"path": safe_path,
|
||||
"width": image.get_width(),
|
||||
"height": image.get_height(),
|
||||
}
|
||||
|
||||
var preview_max_width: int = int(payload.get("preview_max_width", 0))
|
||||
var preview_max_height: int = int(payload.get("preview_max_height", 0))
|
||||
if preview_max_width > 0 and preview_max_height > 0:
|
||||
var scale: float = min(
|
||||
1.0,
|
||||
min(
|
||||
float(preview_max_width) / float(image.get_width()),
|
||||
float(preview_max_height) / float(image.get_height())
|
||||
)
|
||||
)
|
||||
var preview_width: int = max(1, int(floor(float(image.get_width()) * scale)))
|
||||
var preview_height: int = max(1, int(floor(float(image.get_height()) * scale)))
|
||||
# Full image already saved to disk — resize in-place to avoid a redundant copy
|
||||
image.resize(preview_width, preview_height, Image.INTERPOLATE_LANCZOS)
|
||||
var preview_path: String = screenshot_dir.path_join("screenshot_%s_preview.png" % timestamp)
|
||||
var preview_err: Error = image.save_png(preview_path)
|
||||
if preview_err != OK:
|
||||
_send_response(peer, {"error": "Failed to save screenshot preview (error %d)" % preview_err})
|
||||
return
|
||||
response["preview_path"] = preview_path.replace("\\", "/")
|
||||
response["preview_width"] = preview_width
|
||||
response["preview_height"] = preview_height
|
||||
|
||||
_send_response(peer, response)
|
||||
|
||||
# --- Input Simulation ---
|
||||
|
||||
func _handle_input(peer: PeerState, actions: Array) -> void:
|
||||
var processed := 0
|
||||
var error_msg := ""
|
||||
|
||||
for action in actions:
|
||||
if typeof(action) != TYPE_DICTIONARY:
|
||||
error_msg = "Action at index %d is not an object" % processed
|
||||
break
|
||||
|
||||
var type = action.get("type", "")
|
||||
match type:
|
||||
"key":
|
||||
var result = _inject_key(action)
|
||||
if result != "":
|
||||
error_msg = "Action %d (key): %s" % [processed, result]
|
||||
break
|
||||
"mouse_button":
|
||||
var result = _inject_mouse_button(action)
|
||||
if result != "":
|
||||
error_msg = "Action %d (mouse_button): %s" % [processed, result]
|
||||
break
|
||||
"mouse_motion":
|
||||
_inject_mouse_motion(action)
|
||||
"action":
|
||||
var result = _inject_action(action)
|
||||
if result != "":
|
||||
error_msg = "Action %d (action): %s" % [processed, result]
|
||||
break
|
||||
"click_element":
|
||||
var result = _inject_click_element(action)
|
||||
if result != "":
|
||||
error_msg = "Action %d (click_element): %s" % [processed, result]
|
||||
break
|
||||
"wait":
|
||||
var ms = action.get("ms", 0)
|
||||
if typeof(ms) == TYPE_FLOAT or typeof(ms) == TYPE_INT:
|
||||
if ms > 0:
|
||||
await get_tree().create_timer(ms / 1000.0).timeout
|
||||
else:
|
||||
error_msg = "Action %d (wait): ms must be a number" % processed
|
||||
break
|
||||
_:
|
||||
error_msg = "Action %d: unknown type '%s'" % [processed, type]
|
||||
break
|
||||
|
||||
processed += 1
|
||||
|
||||
# Allow queued input events to dispatch and any signal handlers
|
||||
# (and their runtime errors) to fire before we reply, so the
|
||||
# Node-side stderr scan in sendCommandWithErrors sees them.
|
||||
await get_tree().process_frame
|
||||
await get_tree().process_frame
|
||||
|
||||
if error_msg != "":
|
||||
_send_response(peer, {"error": error_msg, "actions_processed": processed})
|
||||
else:
|
||||
_send_response(peer, {"success": true, "actions_processed": processed})
|
||||
|
||||
func _inject_key(action: Dictionary) -> String:
|
||||
var key_name = action.get("key", "")
|
||||
if key_name == "":
|
||||
return "key name is required"
|
||||
|
||||
var keycode = OS.find_keycode_from_string(key_name)
|
||||
if keycode == KEY_NONE:
|
||||
return "unrecognized key name: '%s'" % key_name
|
||||
|
||||
var event = InputEventKey.new()
|
||||
event.keycode = keycode
|
||||
event.physical_keycode = keycode
|
||||
event.pressed = action.get("pressed", true)
|
||||
event.echo = false
|
||||
event.shift_pressed = action.get("shift", false)
|
||||
event.ctrl_pressed = action.get("ctrl", false)
|
||||
event.alt_pressed = action.get("alt", false)
|
||||
# Text-entry Controls (LineEdit, TextEdit) consume `event.unicode`, not just
|
||||
# the keycode — without it, typing into a focused LineEdit produces nothing.
|
||||
# Auto-derive for ASCII letters and digits; fall back to caller-supplied
|
||||
# `unicode` for symbols and non-ASCII.
|
||||
if action.has("unicode"):
|
||||
event.unicode = int(action.unicode)
|
||||
elif keycode >= KEY_A and keycode <= KEY_Z:
|
||||
event.unicode = keycode if event.shift_pressed else (keycode + 32)
|
||||
elif keycode >= KEY_0 and keycode <= KEY_9:
|
||||
event.unicode = keycode
|
||||
Input.parse_input_event(event)
|
||||
return ""
|
||||
|
||||
func _resolve_button_name(button_name: String) -> Array:
|
||||
match button_name:
|
||||
"left":
|
||||
return [MOUSE_BUTTON_LEFT, ""]
|
||||
"right":
|
||||
return [MOUSE_BUTTON_RIGHT, ""]
|
||||
"middle":
|
||||
return [MOUSE_BUTTON_MIDDLE, ""]
|
||||
_:
|
||||
return [MOUSE_BUTTON_NONE, "unknown button: '%s' (use 'left', 'right', or 'middle')" % button_name]
|
||||
|
||||
func _inject_mouse_button(action: Dictionary) -> String:
|
||||
var button_result := _resolve_button_name(action.get("button", "left"))
|
||||
if button_result[1] != "":
|
||||
return button_result[1]
|
||||
var button_index: MouseButton = button_result[0]
|
||||
|
||||
var pos = Vector2(action.get("x", 0), action.get("y", 0))
|
||||
var double_click = action.get("double_click", false)
|
||||
|
||||
# If pressed is explicitly set, only do that one event
|
||||
if action.has("pressed"):
|
||||
var event = InputEventMouseButton.new()
|
||||
event.button_index = button_index
|
||||
event.pressed = action.get("pressed")
|
||||
event.position = pos
|
||||
event.global_position = pos
|
||||
event.double_click = double_click
|
||||
Input.parse_input_event(event)
|
||||
else:
|
||||
# Auto press + release (click)
|
||||
var press = InputEventMouseButton.new()
|
||||
press.button_index = button_index
|
||||
press.pressed = true
|
||||
press.position = pos
|
||||
press.global_position = pos
|
||||
press.double_click = double_click
|
||||
Input.parse_input_event(press)
|
||||
|
||||
var release = InputEventMouseButton.new()
|
||||
release.button_index = button_index
|
||||
release.pressed = false
|
||||
release.position = pos
|
||||
release.global_position = pos
|
||||
Input.parse_input_event(release)
|
||||
|
||||
return ""
|
||||
|
||||
func _inject_mouse_motion(action: Dictionary) -> void:
|
||||
var event = InputEventMouseMotion.new()
|
||||
event.position = Vector2(action.get("x", 0), action.get("y", 0))
|
||||
event.global_position = event.position
|
||||
event.relative = Vector2(action.get("relative_x", 0), action.get("relative_y", 0))
|
||||
Input.parse_input_event(event)
|
||||
|
||||
func _inject_action(action: Dictionary) -> String:
|
||||
var action_name = action.get("action", "")
|
||||
if action_name == "":
|
||||
return "action name is required"
|
||||
|
||||
var pressed = action.get("pressed", true)
|
||||
var strength = action.get("strength", 1.0)
|
||||
|
||||
if pressed:
|
||||
Input.action_press(action_name, strength)
|
||||
else:
|
||||
Input.action_release(action_name)
|
||||
return ""
|
||||
|
||||
func _inject_click_element(action: Dictionary) -> String:
|
||||
var identifier: String = action.get("element", "")
|
||||
if identifier == "":
|
||||
return "element identifier is required"
|
||||
|
||||
var target := _find_control_by_identifier(identifier)
|
||||
if target == null:
|
||||
return "Could not find UI element: %s" % identifier
|
||||
|
||||
if not target.is_visible_in_tree():
|
||||
return "UI element '%s' is not visible" % identifier
|
||||
|
||||
var button_result := _resolve_button_name(action.get("button", "left"))
|
||||
if button_result[1] != "":
|
||||
return button_result[1]
|
||||
var button_index: MouseButton = button_result[0]
|
||||
var double_click: bool = action.get("double_click", false)
|
||||
var rect := target.get_global_rect()
|
||||
var center := rect.get_center()
|
||||
|
||||
var press := InputEventMouseButton.new()
|
||||
press.button_index = button_index
|
||||
press.pressed = true
|
||||
press.position = center
|
||||
press.global_position = center
|
||||
press.double_click = double_click
|
||||
Input.parse_input_event(press)
|
||||
|
||||
var release := InputEventMouseButton.new()
|
||||
release.button_index = button_index
|
||||
release.pressed = false
|
||||
release.position = center
|
||||
release.global_position = center
|
||||
Input.parse_input_event(release)
|
||||
|
||||
return ""
|
||||
|
||||
# --- UI Element Discovery ---
|
||||
|
||||
func _handle_get_ui_elements(peer: PeerState, payload: Dictionary) -> void:
|
||||
var visible_only: bool = payload.get("visible_only", true)
|
||||
var type_filter: String = payload.get("type_filter", "")
|
||||
var root := get_tree().root
|
||||
var elements: Array[Dictionary] = []
|
||||
_collect_control_nodes(root, elements, visible_only, type_filter)
|
||||
_send_response(peer, {"elements": elements})
|
||||
|
||||
func _collect_control_nodes(node: Node, elements: Array[Dictionary], visible_only: bool, type_filter: String = "") -> void:
|
||||
if node is Control:
|
||||
var ctrl := node as Control
|
||||
if visible_only and not ctrl.is_visible_in_tree():
|
||||
return
|
||||
if type_filter != "" and not ctrl.is_class(type_filter):
|
||||
# Still recurse into children even if this node doesn't match
|
||||
for child in node.get_children():
|
||||
_collect_control_nodes(child, elements, visible_only, type_filter)
|
||||
return
|
||||
var rect := ctrl.get_global_rect()
|
||||
var element := {
|
||||
"name": String(ctrl.name),
|
||||
"type": ctrl.get_class(),
|
||||
"path": str(ctrl.get_path()),
|
||||
"rect": {
|
||||
"x": rect.position.x,
|
||||
"y": rect.position.y,
|
||||
"width": rect.size.x,
|
||||
"height": rect.size.y,
|
||||
},
|
||||
"visible": ctrl.is_visible_in_tree(),
|
||||
}
|
||||
# Extract text content for common Control types
|
||||
if ctrl is Button:
|
||||
element["text"] = (ctrl as Button).text
|
||||
elif ctrl is Label:
|
||||
element["text"] = (ctrl as Label).text
|
||||
elif ctrl is LineEdit:
|
||||
element["text"] = (ctrl as LineEdit).text
|
||||
element["placeholder"] = (ctrl as LineEdit).placeholder_text
|
||||
elif ctrl is TextEdit:
|
||||
element["text"] = (ctrl as TextEdit).text
|
||||
elif ctrl is RichTextLabel:
|
||||
element["text"] = (ctrl as RichTextLabel).text
|
||||
# Disabled state for buttons
|
||||
if ctrl is BaseButton:
|
||||
element["disabled"] = (ctrl as BaseButton).disabled
|
||||
# Tooltip
|
||||
if ctrl.tooltip_text != "":
|
||||
element["tooltip"] = ctrl.tooltip_text
|
||||
elements.append(element)
|
||||
for child in node.get_children():
|
||||
_collect_control_nodes(child, elements, visible_only, type_filter)
|
||||
|
||||
func _find_control_by_identifier(identifier: String) -> Control:
|
||||
var root := get_tree().root
|
||||
# Try as node path first
|
||||
if identifier.begins_with("/"):
|
||||
var abs_node := root.get_node_or_null(NodePath(identifier))
|
||||
if abs_node is Control:
|
||||
return abs_node as Control
|
||||
# Try as relative path from root
|
||||
var node := root.get_node_or_null(NodePath(identifier))
|
||||
if node is Control:
|
||||
return node as Control
|
||||
# BFS: match by node name
|
||||
var queue: Array[Node] = []
|
||||
queue.append(root)
|
||||
while not queue.is_empty():
|
||||
var current: Node = queue.pop_front()
|
||||
if current is Control:
|
||||
if String(current.name) == identifier:
|
||||
return current as Control
|
||||
for child in current.get_children():
|
||||
queue.append(child)
|
||||
return null
|
||||
|
||||
# --- Script Execution ---
|
||||
|
||||
func _handle_run_script(peer: PeerState, payload: Dictionary) -> void:
|
||||
var source: String = payload.get("source", "")
|
||||
if source.strip_edges() == "":
|
||||
_send_response(peer, {"error": "No script source provided"})
|
||||
return
|
||||
|
||||
# Compile the script at runtime
|
||||
var script := GDScript.new()
|
||||
script.source_code = source
|
||||
var err := script.reload()
|
||||
if err != OK:
|
||||
_send_response(peer, {"error": "Script compilation failed (error %d). Check syntax." % err})
|
||||
return
|
||||
|
||||
# Instantiate and validate
|
||||
var instance = script.new()
|
||||
if instance == null:
|
||||
_send_response(peer, {"error": "Failed to instantiate script"})
|
||||
return
|
||||
|
||||
if not instance.has_method("execute"):
|
||||
if instance is RefCounted:
|
||||
instance = null # Let RefCounted free itself
|
||||
else:
|
||||
instance.free()
|
||||
_send_response(peer, {"error": "Script must define func execute(scene_tree: SceneTree) -> Variant"})
|
||||
return
|
||||
|
||||
# Execute (await in case the user's script uses async/await internally)
|
||||
var result = await instance.execute(get_tree())
|
||||
|
||||
# Clean up
|
||||
if instance is RefCounted:
|
||||
instance = null
|
||||
else:
|
||||
instance.free()
|
||||
|
||||
# Serialize and respond
|
||||
var serialized = _serialize_value(result)
|
||||
_send_response(peer, {"success": true, "result": serialized})
|
||||
|
||||
func _serialize_value(value: Variant) -> Variant:
|
||||
if value == null:
|
||||
return null
|
||||
|
||||
match typeof(value):
|
||||
TYPE_BOOL, TYPE_INT, TYPE_FLOAT, TYPE_STRING:
|
||||
return value
|
||||
TYPE_VECTOR2:
|
||||
var v: Vector2 = value
|
||||
return {"x": v.x, "y": v.y}
|
||||
TYPE_VECTOR2I:
|
||||
var v: Vector2i = value
|
||||
return {"x": v.x, "y": v.y}
|
||||
TYPE_VECTOR3:
|
||||
var v: Vector3 = value
|
||||
return {"x": v.x, "y": v.y, "z": v.z}
|
||||
TYPE_VECTOR3I:
|
||||
var v: Vector3i = value
|
||||
return {"x": v.x, "y": v.y, "z": v.z}
|
||||
TYPE_COLOR:
|
||||
var c: Color = value
|
||||
return {"r": c.r, "g": c.g, "b": c.b, "a": c.a}
|
||||
TYPE_DICTIONARY:
|
||||
var d: Dictionary = value
|
||||
var result := {}
|
||||
for key in d:
|
||||
result[str(key)] = _serialize_value(d[key])
|
||||
return result
|
||||
TYPE_ARRAY:
|
||||
var a: Array = value
|
||||
var result := []
|
||||
for item in a:
|
||||
result.append(_serialize_value(item))
|
||||
return result
|
||||
TYPE_OBJECT:
|
||||
if value is Node:
|
||||
var node: Node = value
|
||||
return {"class": node.get_class(), "name": String(node.name), "path": str(node.get_path())}
|
||||
elif value is Resource:
|
||||
var res: Resource = value
|
||||
return {"class": res.get_class(), "path": res.resource_path}
|
||||
else:
|
||||
return str(value)
|
||||
_:
|
||||
return str(value)
|
||||
|
||||
# --- Shutdown ---
|
||||
|
||||
func _handle_shutdown(peer: PeerState) -> void:
|
||||
_shutting_down = true
|
||||
_send_response(peer, {"status": "shutting_down"})
|
||||
# Let the response flush before we tear the listener down. A new command
|
||||
# arriving in this 2-frame window would dispatch against a peer that's
|
||||
# about to close; the response write fails gracefully and the Node side
|
||||
# sees BridgeDisconnectedError. MCP serializes calls so this is theoretical.
|
||||
await get_tree().process_frame
|
||||
await get_tree().process_frame
|
||||
_close_all_peers()
|
||||
if tcp_server != null:
|
||||
tcp_server.stop()
|
||||
# Detach from the tree so subsequent _process ticks don't run.
|
||||
queue_free()
|
||||
|
||||
# --- Utility ---
|
||||
|
||||
func _send_response(peer: PeerState, data: Dictionary) -> void:
|
||||
var resp := JSON.stringify(data)
|
||||
var body := resp.to_utf8_buffer()
|
||||
if body.size() > MAX_FRAME_BYTES:
|
||||
push_error("McpBridge: Response exceeds %d bytes; dropping" % MAX_FRAME_BYTES)
|
||||
peer.handling = false
|
||||
return
|
||||
if peer.stream != null and peer.stream.get_status() == StreamPeerTCP.STATUS_CONNECTED:
|
||||
var header := PackedByteArray()
|
||||
header.resize(FRAME_HEADER_BYTES)
|
||||
var size := body.size()
|
||||
header[0] = (size >> 24) & 0xFF
|
||||
header[1] = (size >> 16) & 0xFF
|
||||
header[2] = (size >> 8) & 0xFF
|
||||
header[3] = size & 0xFF
|
||||
peer.stream.put_data(header)
|
||||
peer.stream.put_data(body)
|
||||
peer.handling = false
|
||||
|
||||
func _close_all_peers() -> void:
|
||||
for peer in _peers:
|
||||
if peer.stream != null:
|
||||
peer.stream.disconnect_from_host()
|
||||
peer.stream = null
|
||||
_peers.clear()
|
||||
|
||||
func _exit_tree() -> void:
|
||||
if not _shutting_down:
|
||||
push_warning("McpBridge: removed from tree without shutdown — bridge connection will be lost")
|
||||
_close_all_peers()
|
||||
if tcp_server != null:
|
||||
tcp_server.stop()
|
||||
tcp_server = null
|
||||
print("McpBridge: Stopped")
|
||||
+1
@@ -0,0 +1 @@
|
||||
uid://3qvnaku3p3c3
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
import type { OperationParams, ToolDefinition } from '../utils/godot-runner.js';
|
||||
export declare const autoloadToolDefinitions: ToolDefinition[];
|
||||
export declare function handleListAutoloads(args: OperationParams): {
|
||||
content: Array<{
|
||||
type: "text";
|
||||
text: string;
|
||||
}>;
|
||||
isError: boolean;
|
||||
} | {
|
||||
content: {
|
||||
type: string;
|
||||
text: string;
|
||||
}[];
|
||||
};
|
||||
export declare function handleAddAutoload(args: OperationParams): {
|
||||
content: Array<{
|
||||
type: "text";
|
||||
text: string;
|
||||
}>;
|
||||
isError: boolean;
|
||||
} | {
|
||||
content: {
|
||||
type: string;
|
||||
text: string;
|
||||
}[];
|
||||
};
|
||||
export declare function handleRemoveAutoload(args: OperationParams): {
|
||||
content: Array<{
|
||||
type: "text";
|
||||
text: string;
|
||||
}>;
|
||||
isError: boolean;
|
||||
} | {
|
||||
content: {
|
||||
type: string;
|
||||
text: string;
|
||||
}[];
|
||||
};
|
||||
export declare function handleUpdateAutoload(args: OperationParams): {
|
||||
content: Array<{
|
||||
type: "text";
|
||||
text: string;
|
||||
}>;
|
||||
isError: boolean;
|
||||
} | {
|
||||
content: {
|
||||
type: string;
|
||||
text: string;
|
||||
}[];
|
||||
};
|
||||
//# sourceMappingURL=autoload-tools.d.ts.map
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"autoload-tools.d.ts","sourceRoot":"","sources":["../../src/tools/autoload-tools.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAkBhF,eAAO,MAAM,uBAAuB,EAAE,cAAc,EAqEnD,CAAC;AAIF,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,eAAe;;;;;;;;;;;EAcxD;AAED,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,eAAe;;;;;;;;;;;EA+CtD;AAED,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,eAAe;;;;;;;;;;;EA2BzD;AAED,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,eAAe;;;;;;;;;;;EAsCzD"}
|
||||
Vendored
+191
@@ -0,0 +1,191 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { normalizeParameters, validateSubPath, validateProjectArgs, createErrorResponse, getErrorMessage, projectGodotPath, } from '../utils/godot-runner.js';
|
||||
import { parseAutoloads, addAutoloadEntry, removeAutoloadEntry, updateAutoloadEntry, } from '../utils/autoload-ini.js';
|
||||
// --- Tool definitions ---
|
||||
export const autoloadToolDefinitions = [
|
||||
{
|
||||
name: 'list_autoloads',
|
||||
description: 'List all registered autoloads in a project with paths and singleton status. Use first when diagnosing headless failures — broken autoloads crash all headless ops, so this tells you what is loaded. No Godot process required (reads project.godot directly). Returns: [{ name, path, singleton }].',
|
||||
annotations: { readOnlyHint: true },
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
projectPath: { type: 'string', description: 'Path to the Godot project directory' },
|
||||
},
|
||||
required: ['projectPath'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'add_autoload',
|
||||
description: 'Register a new autoload in a project. autoloadPath accepts "res://..." or a project-relative path (auto-prefixed). singleton defaults true (accessible globally by name). No Godot process required. Warning: autoloads initialize in headless mode — a broken script will crash every subsequent headless op; validate before adding. Returns plain-text confirmation with the registered name, path, and singleton flag. Errors if an autoload with the same name already exists; use update_autoload to modify.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
projectPath: { type: 'string', description: 'Path to the Godot project directory' },
|
||||
autoloadName: {
|
||||
type: 'string',
|
||||
description: 'Name of the autoload node (e.g. "MyManager")',
|
||||
},
|
||||
autoloadPath: {
|
||||
type: 'string',
|
||||
description: 'Path to the script or scene (e.g. "res://autoload/my_manager.gd" or "autoload/my_manager.gd")',
|
||||
},
|
||||
singleton: {
|
||||
type: 'boolean',
|
||||
description: 'Register as a globally accessible singleton by name (default: true)',
|
||||
},
|
||||
},
|
||||
required: ['projectPath', 'autoloadName', 'autoloadPath'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'remove_autoload',
|
||||
description: 'Unregister an autoload from a project by name. Use to recover from a broken autoload that is crashing headless ops. No Godot process required. Returns plain-text confirmation on success. Errors if no autoload with that name exists.',
|
||||
annotations: { destructiveHint: true },
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
projectPath: { type: 'string', description: 'Path to the Godot project directory' },
|
||||
autoloadName: { type: 'string', description: 'Name of the autoload to remove' },
|
||||
},
|
||||
required: ['projectPath', 'autoloadName'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'update_autoload',
|
||||
description: "Modify an existing autoload's path or singleton flag. Pass either or both — omitted fields keep their current value. Use instead of remove_autoload + add_autoload (single edit, no orphan window). No Godot process required. Returns plain-text confirmation on success. Errors if autoloadName is not registered.",
|
||||
annotations: { idempotentHint: true },
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
projectPath: { type: 'string', description: 'Path to the Godot project directory' },
|
||||
autoloadName: { type: 'string', description: 'Name of the autoload to update' },
|
||||
autoloadPath: { type: 'string', description: 'New path to the script or scene' },
|
||||
singleton: { type: 'boolean', description: 'New singleton flag' },
|
||||
},
|
||||
required: ['projectPath', 'autoloadName'],
|
||||
},
|
||||
},
|
||||
];
|
||||
// --- Handlers ---
|
||||
export function handleListAutoloads(args) {
|
||||
args = normalizeParameters(args);
|
||||
const v = validateProjectArgs(args);
|
||||
if ('isError' in v)
|
||||
return v;
|
||||
try {
|
||||
const projectFile = projectGodotPath(v.projectPath);
|
||||
const autoloads = parseAutoloads(projectFile);
|
||||
return { content: [{ type: 'text', text: JSON.stringify(autoloads) }] };
|
||||
}
|
||||
catch (error) {
|
||||
return createErrorResponse(`Failed to list autoloads: ${getErrorMessage(error)}`, [
|
||||
'Check if project.godot is accessible',
|
||||
]);
|
||||
}
|
||||
}
|
||||
export function handleAddAutoload(args) {
|
||||
args = normalizeParameters(args);
|
||||
const v = validateProjectArgs(args);
|
||||
if ('isError' in v)
|
||||
return v;
|
||||
if (!args.autoloadName || !args.autoloadPath) {
|
||||
return createErrorResponse('autoloadName and autoloadPath are required', [
|
||||
'Provide the autoload node name and script/scene path',
|
||||
]);
|
||||
}
|
||||
if (!validateSubPath(v.projectPath, args.autoloadPath)) {
|
||||
return createErrorResponse('Invalid autoload path', [
|
||||
'Provide a valid relative path or res:// URI that stays inside the project directory',
|
||||
]);
|
||||
}
|
||||
try {
|
||||
const projectFile = projectGodotPath(v.projectPath);
|
||||
const projectFileContent = readFileSync(projectFile, 'utf8');
|
||||
const existing = parseAutoloads(projectFile, projectFileContent);
|
||||
if (existing.some((a) => a.name === args.autoloadName)) {
|
||||
return createErrorResponse(`Autoload '${args.autoloadName}' already exists`, [
|
||||
'Use update_autoload to modify it',
|
||||
'Use list_autoloads to see current autoloads',
|
||||
]);
|
||||
}
|
||||
const isSingleton = args.singleton !== false;
|
||||
addAutoloadEntry(projectFile, args.autoloadName, args.autoloadPath, isSingleton, projectFileContent);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Autoload '${args.autoloadName}' registered at '${args.autoloadPath}' (singleton: ${isSingleton}).\nWarning: autoloads initialize in headless mode too. If this script has errors, all headless operations will fail. Verify by running get_scene_tree — if it fails, use remove_autoload to remove it.`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
catch (error) {
|
||||
return createErrorResponse(`Failed to add autoload: ${getErrorMessage(error)}`, [
|
||||
'Check if project.godot is accessible',
|
||||
]);
|
||||
}
|
||||
}
|
||||
export function handleRemoveAutoload(args) {
|
||||
args = normalizeParameters(args);
|
||||
const v = validateProjectArgs(args);
|
||||
if ('isError' in v)
|
||||
return v;
|
||||
if (!args.autoloadName) {
|
||||
return createErrorResponse('autoloadName is required', [
|
||||
'Provide the name of the autoload to remove',
|
||||
]);
|
||||
}
|
||||
try {
|
||||
const projectFile = projectGodotPath(v.projectPath);
|
||||
const removed = removeAutoloadEntry(projectFile, args.autoloadName);
|
||||
if (!removed) {
|
||||
return createErrorResponse(`Autoload '${args.autoloadName}' not found`, [
|
||||
'Use list_autoloads to see existing autoloads',
|
||||
]);
|
||||
}
|
||||
return {
|
||||
content: [{ type: 'text', text: `Autoload '${args.autoloadName}' removed successfully.` }],
|
||||
};
|
||||
}
|
||||
catch (error) {
|
||||
return createErrorResponse(`Failed to remove autoload: ${getErrorMessage(error)}`, [
|
||||
'Check if project.godot is accessible',
|
||||
]);
|
||||
}
|
||||
}
|
||||
export function handleUpdateAutoload(args) {
|
||||
args = normalizeParameters(args);
|
||||
const v = validateProjectArgs(args);
|
||||
if ('isError' in v)
|
||||
return v;
|
||||
if (!args.autoloadName) {
|
||||
return createErrorResponse('autoloadName is required', [
|
||||
'Provide the name of the autoload to update',
|
||||
]);
|
||||
}
|
||||
if (args.autoloadPath && !validateSubPath(v.projectPath, args.autoloadPath)) {
|
||||
return createErrorResponse('Invalid autoload path', [
|
||||
'Provide a valid relative path or res:// URI that stays inside the project directory',
|
||||
]);
|
||||
}
|
||||
try {
|
||||
const projectFile = projectGodotPath(v.projectPath);
|
||||
const updated = updateAutoloadEntry(projectFile, args.autoloadName, args.autoloadPath, args.singleton);
|
||||
if (!updated) {
|
||||
return createErrorResponse(`Autoload '${args.autoloadName}' not found`, [
|
||||
'Use list_autoloads to see existing autoloads',
|
||||
'Use add_autoload to register a new one',
|
||||
]);
|
||||
}
|
||||
return {
|
||||
content: [{ type: 'text', text: `Autoload '${args.autoloadName}' updated successfully.` }],
|
||||
};
|
||||
}
|
||||
catch (error) {
|
||||
return createErrorResponse(`Failed to update autoload: ${getErrorMessage(error)}`, [
|
||||
'Check if project.godot is accessible',
|
||||
]);
|
||||
}
|
||||
}
|
||||
//# sourceMappingURL=autoload-tools.js.map
|
||||
+1
File diff suppressed because one or more lines are too long
Vendored
+66
@@ -0,0 +1,66 @@
|
||||
import type { GodotRunner, OperationParams, ToolDefinition } from '../utils/godot-runner.js';
|
||||
export declare const nodeToolDefinitions: ToolDefinition[];
|
||||
export declare function handleDeleteNodes(runner: GodotRunner, args: OperationParams): Promise<import("../utils/godot-runner.js").ToolResponse | {
|
||||
content: Array<{
|
||||
type: "text";
|
||||
text: string;
|
||||
}>;
|
||||
isError: boolean;
|
||||
}>;
|
||||
export declare function handleSetNodeProperties(runner: GodotRunner, args: OperationParams): Promise<import("../utils/godot-runner.js").ToolResponse | {
|
||||
content: Array<{
|
||||
type: "text";
|
||||
text: string;
|
||||
}>;
|
||||
isError: boolean;
|
||||
}>;
|
||||
export declare function handleGetNodeProperties(runner: GodotRunner, args: OperationParams): Promise<import("../utils/godot-runner.js").ToolResponse | {
|
||||
content: Array<{
|
||||
type: "text";
|
||||
text: string;
|
||||
}>;
|
||||
isError: boolean;
|
||||
}>;
|
||||
export declare function handleAttachScript(runner: GodotRunner, args: OperationParams): Promise<import("../utils/godot-runner.js").ToolResponse | {
|
||||
content: Array<{
|
||||
type: "text";
|
||||
text: string;
|
||||
}>;
|
||||
isError: boolean;
|
||||
}>;
|
||||
export declare function handleGetSceneTree(runner: GodotRunner, args: OperationParams): Promise<import("../utils/godot-runner.js").ToolResponse | {
|
||||
content: Array<{
|
||||
type: "text";
|
||||
text: string;
|
||||
}>;
|
||||
isError: boolean;
|
||||
}>;
|
||||
export declare function handleDuplicateNode(runner: GodotRunner, args: OperationParams): Promise<import("../utils/godot-runner.js").ToolResponse | {
|
||||
content: Array<{
|
||||
type: "text";
|
||||
text: string;
|
||||
}>;
|
||||
isError: boolean;
|
||||
}>;
|
||||
export declare function handleGetNodeSignals(runner: GodotRunner, args: OperationParams): Promise<import("../utils/godot-runner.js").ToolResponse | {
|
||||
content: Array<{
|
||||
type: "text";
|
||||
text: string;
|
||||
}>;
|
||||
isError: boolean;
|
||||
}>;
|
||||
export declare function handleConnectSignal(runner: GodotRunner, args: OperationParams): Promise<import("../utils/godot-runner.js").ToolResponse | {
|
||||
content: Array<{
|
||||
type: "text";
|
||||
text: string;
|
||||
}>;
|
||||
isError: boolean;
|
||||
}>;
|
||||
export declare function handleDisconnectSignal(runner: GodotRunner, args: OperationParams): Promise<import("../utils/godot-runner.js").ToolResponse | {
|
||||
content: Array<{
|
||||
type: "text";
|
||||
text: string;
|
||||
}>;
|
||||
isError: boolean;
|
||||
}>;
|
||||
//# sourceMappingURL=node-tools.d.ts.map
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"node-tools.d.ts","sourceRoot":"","sources":["../../src/tools/node-tools.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,WAAW,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAY7F,eAAO,MAAM,mBAAmB,EAAE,cAAc,EAqS/C,CAAC;AAIF,wBAAsB,iBAAiB,CAAC,MAAM,EAAE,WAAW,EAAE,IAAI,EAAE,eAAe;;;;;;GA6BjF;AAED,wBAAsB,uBAAuB,CAAC,MAAM,EAAE,WAAW,EAAE,IAAI,EAAE,eAAe;;;;;;GA0BvF;AAED,wBAAsB,uBAAuB,CAAC,MAAM,EAAE,WAAW,EAAE,IAAI,EAAE,eAAe;;;;;;GAoBvF;AAED,wBAAsB,kBAAkB,CAAC,MAAM,EAAE,WAAW,EAAE,IAAI,EAAE,eAAe;;;;;;GAqClF;AAED,wBAAsB,kBAAkB,CAAC,MAAM,EAAE,WAAW,EAAE,IAAI,EAAE,eAAe;;;;;;GAsBlF;AAED,wBAAsB,mBAAmB,CAAC,MAAM,EAAE,WAAW,EAAE,IAAI,EAAE,eAAe;;;;;;GA6BnF;AAED,wBAAsB,oBAAoB,CAAC,MAAM,EAAE,WAAW,EAAE,IAAI,EAAE,eAAe;;;;;;GAsBpF;AAyCD,wBAAsB,mBAAmB,CAAC,MAAM,EAAE,WAAW,EAAE,IAAI,EAAE,eAAe;;;;;;GAoBnF;AAED,wBAAsB,sBAAsB,CAAC,MAAM,EAAE,WAAW,EAAE,IAAI,EAAE,eAAe;;;;;;GAoBtF"}
|
||||
Vendored
+473
@@ -0,0 +1,473 @@
|
||||
import { existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { normalizeParameters, validateSubPath, validateNodePath, createErrorResponse, validateSceneArgs, } from '../utils/godot-runner.js';
|
||||
import { executeSceneOp } from '../utils/handler-helpers.js';
|
||||
// --- Tool definitions ---
|
||||
export const nodeToolDefinitions = [
|
||||
{
|
||||
name: 'delete_nodes',
|
||||
description: 'Remove one or more nodes (and their descendants) from a scene file. Always-array: pass a single-element nodePaths array for one-off deletes. Saves once at the end. Cannot delete the scene root — that entry returns an error and the rest still process. Returns: results array with one entry per nodePath in input order (success or error message).',
|
||||
annotations: { destructiveHint: true },
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
projectPath: { type: 'string', description: 'Path to the Godot project directory' },
|
||||
scenePath: {
|
||||
type: 'string',
|
||||
description: 'Scene file path relative to the project (e.g. "scenes/main.tscn")',
|
||||
},
|
||||
nodePaths: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'Node paths from scene root to delete (e.g. ["root/Player/Sprite2D"])',
|
||||
},
|
||||
},
|
||||
required: ['projectPath', 'scenePath', 'nodePaths'],
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
results: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
nodePath: { type: 'string' },
|
||||
success: { type: 'boolean' },
|
||||
error: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'set_node_properties',
|
||||
description: 'Set one or more node properties on a scene in a single Godot process. Always-array: pass a single-element updates array for one-off edits. Vector2 ({x,y}), Vector3 ({x,y,z}), and Color ({r,g,b,a}) auto-convert; primitives pass through. For other complex GDScript types (Resource, NodePath, etc.), use run_script. abortOnError stops on first failure (default false continues). Saves once at the end. Returns: results[] with one entry per update in input order (success or error).',
|
||||
annotations: { idempotentHint: true },
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
projectPath: { type: 'string', description: 'Path to the Godot project directory' },
|
||||
scenePath: { type: 'string', description: 'Scene file path relative to the project' },
|
||||
updates: {
|
||||
type: 'array',
|
||||
description: 'Property updates to apply',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
nodePath: {
|
||||
type: 'string',
|
||||
description: 'Node path from scene root (e.g. "root/Player")',
|
||||
},
|
||||
property: {
|
||||
type: 'string',
|
||||
description: 'GDScript property name in snake_case (e.g. "position", "modulate", "collision_layer")',
|
||||
},
|
||||
value: { description: 'New property value' },
|
||||
},
|
||||
required: ['nodePath', 'property', 'value'],
|
||||
},
|
||||
},
|
||||
abortOnError: {
|
||||
type: 'boolean',
|
||||
description: 'Stop processing on first error (default: false)',
|
||||
},
|
||||
},
|
||||
required: ['projectPath', 'scenePath', 'updates'],
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
results: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
nodePath: { type: 'string' },
|
||||
property: { type: 'string' },
|
||||
success: { type: 'boolean' },
|
||||
error: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_node_properties',
|
||||
description: "Read one or more nodes' current property values from a scene file in a single Godot process. Always-array: pass a single-element nodes array for one-off reads. Per-node changedOnly:true filters out properties matching class defaults (useful for compact diffs). Returns: { results: [{ nodePath, nodeType, properties?, error? }] }; failed reads include error and omit properties.",
|
||||
annotations: { readOnlyHint: true },
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
projectPath: { type: 'string', description: 'Path to the Godot project directory' },
|
||||
scenePath: { type: 'string', description: 'Scene file path relative to the project' },
|
||||
nodes: {
|
||||
type: 'array',
|
||||
description: 'Nodes to read properties from',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
nodePath: {
|
||||
type: 'string',
|
||||
description: 'Node path from scene root (e.g. "root/Player")',
|
||||
},
|
||||
changedOnly: {
|
||||
type: 'boolean',
|
||||
description: 'Only return properties differing from defaults (default: false)',
|
||||
},
|
||||
},
|
||||
required: ['nodePath'],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['projectPath', 'scenePath', 'nodes'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'attach_script',
|
||||
description: 'Attach an existing GDScript file to a node in a scene. Use after writing the script with the standard file tools and validating it via the validate tool. Replaces any previously attached script. Saves automatically. Returns: success with the resolved nodePath and scriptPath that were attached. Errors if scriptPath does not exist or nodePath is not found.',
|
||||
annotations: { idempotentHint: true },
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
projectPath: { type: 'string', description: 'Path to the Godot project directory' },
|
||||
scenePath: { type: 'string', description: 'Scene file path relative to the project' },
|
||||
nodePath: { type: 'string', description: 'Node path from scene root (e.g. "root/Player")' },
|
||||
scriptPath: {
|
||||
type: 'string',
|
||||
description: 'Path to the GDScript file relative to the project (e.g. "scripts/player.gd")',
|
||||
},
|
||||
},
|
||||
required: ['projectPath', 'scenePath', 'nodePath', 'scriptPath'],
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean' },
|
||||
nodePath: { type: 'string' },
|
||||
scriptPath: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_scene_tree',
|
||||
description: 'Get the scene hierarchy as a nested tree of { name, type, path, script, children }. Use maxDepth:1 for a shallow listing of direct children only; default -1 returns the full tree. parentPath scopes the result to a subtree. Returns the nested tree as JSON text. Errors if scene does not exist or parentPath is not found.',
|
||||
annotations: { readOnlyHint: true },
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
projectPath: { type: 'string', description: 'Path to the Godot project directory' },
|
||||
scenePath: { type: 'string', description: 'Scene file path relative to the project' },
|
||||
parentPath: {
|
||||
type: 'string',
|
||||
description: 'Scope to a subtree starting at this node path (e.g. "root/Player")',
|
||||
},
|
||||
maxDepth: {
|
||||
type: 'number',
|
||||
description: 'Maximum recursion depth. -1 for unlimited (default: -1). 1 returns only direct children.',
|
||||
},
|
||||
},
|
||||
required: ['projectPath', 'scenePath'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'duplicate_node',
|
||||
description: 'Duplicate a node and its descendants in a Godot scene. Use to clone a configured subtree without re-creating it node-by-node via add_node. newName defaults to the original name + "2"; targetParentPath defaults to the original parent. Saves automatically. Returns: success with originalPath and the newPath where the duplicate now lives — use newPath for follow-up edits. Errors if nodePath does not exist or targetParentPath cannot accept children.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
projectPath: { type: 'string', description: 'Path to the Godot project directory' },
|
||||
scenePath: { type: 'string', description: 'Scene file path relative to the project' },
|
||||
nodePath: { type: 'string', description: 'Node path from scene root to duplicate' },
|
||||
newName: {
|
||||
type: 'string',
|
||||
description: 'Name for the duplicated node (default: original name + "2")',
|
||||
},
|
||||
targetParentPath: {
|
||||
type: 'string',
|
||||
description: 'Parent node path for the duplicate (default: same parent as original)',
|
||||
},
|
||||
},
|
||||
required: ['projectPath', 'scenePath', 'nodePath'],
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean' },
|
||||
originalPath: { type: 'string' },
|
||||
newPath: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_node_signals',
|
||||
description: 'List all signals defined on a node and their current connections. Use before connect_signal/disconnect_signal to verify signal/method names. The connections[].target field uses Godot absolute path format (/root/Scene/Node) — convert to scene-root-relative (root/Node) before passing to connect/disconnect_signal. Returns: nodeType and signals[], each with name and current connections (signal/target/method). Errors if node not found.',
|
||||
annotations: { readOnlyHint: true },
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
projectPath: { type: 'string', description: 'Path to the Godot project directory' },
|
||||
scenePath: { type: 'string', description: 'Scene file path relative to the project' },
|
||||
nodePath: { type: 'string', description: 'Node path from scene root (e.g. "root/Button")' },
|
||||
},
|
||||
required: ['projectPath', 'scenePath', 'nodePath'],
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
nodePath: { type: 'string' },
|
||||
nodeType: { type: 'string' },
|
||||
signals: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
connections: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
signal: { type: 'string' },
|
||||
target: { type: 'string' },
|
||||
method: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'connect_signal',
|
||||
description: 'Connect a signal on a source node to a method on a target node, persisting the connection in the .tscn. Use after get_node_signals to confirm the signal name on the source and the method name on the target. Connecting the same signal+method pair twice creates a duplicate connection — call get_node_signals first if uncertain. Saves automatically. Returns a plain-text confirmation naming the source, signal, target, and method. Errors if the signal does not exist on the source node or the method does not exist on the target node.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
projectPath: { type: 'string', description: 'Path to the Godot project directory' },
|
||||
scenePath: { type: 'string', description: 'Scene file path relative to the project' },
|
||||
nodePath: { type: 'string', description: 'Source node path from scene root' },
|
||||
signal: {
|
||||
type: 'string',
|
||||
description: 'Signal name on the source node (e.g. "pressed", "body_entered")',
|
||||
},
|
||||
targetNodePath: {
|
||||
type: 'string',
|
||||
description: 'Target node path from scene root that receives the signal',
|
||||
},
|
||||
method: {
|
||||
type: 'string',
|
||||
description: 'Method name on the target node to call when the signal fires',
|
||||
},
|
||||
},
|
||||
required: ['projectPath', 'scenePath', 'nodePath', 'signal', 'targetNodePath', 'method'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'disconnect_signal',
|
||||
description: 'Remove an existing signal connection between two nodes, persisting the change in the .tscn. Use get_node_signals first to confirm the connection exists; recovery requires reconnecting via connect_signal. Saves automatically. Returns a plain-text confirmation naming the disconnected signal and target. Errors if the connection does not exist.',
|
||||
annotations: { destructiveHint: true },
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
projectPath: { type: 'string', description: 'Path to the Godot project directory' },
|
||||
scenePath: { type: 'string', description: 'Scene file path relative to the project' },
|
||||
nodePath: { type: 'string', description: 'Source node path from scene root' },
|
||||
signal: { type: 'string', description: 'Signal name on the source node' },
|
||||
targetNodePath: { type: 'string', description: 'Target node path from scene root' },
|
||||
method: { type: 'string', description: 'Method name on the target node' },
|
||||
},
|
||||
required: ['projectPath', 'scenePath', 'nodePath', 'signal', 'targetNodePath', 'method'],
|
||||
},
|
||||
},
|
||||
];
|
||||
// --- Handlers ---
|
||||
export async function handleDeleteNodes(runner, args) {
|
||||
args = normalizeParameters(args);
|
||||
const v = validateSceneArgs(args);
|
||||
if ('isError' in v)
|
||||
return v;
|
||||
if (!args.nodePaths || !Array.isArray(args.nodePaths) || args.nodePaths.length === 0) {
|
||||
return createErrorResponse('nodePaths array is required', [
|
||||
'Provide a non-empty array of node paths (e.g. ["root/Player"])',
|
||||
]);
|
||||
}
|
||||
for (const p of args.nodePaths) {
|
||||
if (typeof p !== 'string' || !validateNodePath(p)) {
|
||||
return createErrorResponse('Invalid nodePath in nodePaths', [
|
||||
'Provide a scene-tree path without ".." (e.g. "root/Player")',
|
||||
]);
|
||||
}
|
||||
}
|
||||
const params = { scenePath: args.scenePath, nodePaths: args.nodePaths };
|
||||
return executeSceneOp(runner, 'delete_nodes', params, v.projectPath, 'Failed to delete nodes', ['Check if the node paths are correct'], undefined, { parseStdoutAsJson: true });
|
||||
}
|
||||
export async function handleSetNodeProperties(runner, args) {
|
||||
args = normalizeParameters(args);
|
||||
const v = validateSceneArgs(args);
|
||||
if ('isError' in v)
|
||||
return v;
|
||||
if (!args.updates || !Array.isArray(args.updates)) {
|
||||
return createErrorResponse('updates array is required', [
|
||||
'Provide an array of { nodePath, property, value }',
|
||||
]);
|
||||
}
|
||||
const params = {
|
||||
scenePath: args.scenePath,
|
||||
updates: args.updates,
|
||||
abortOnError: args.abortOnError ?? false,
|
||||
};
|
||||
return executeSceneOp(runner, 'set_node_properties', params, v.projectPath, 'Failed to set node properties', ['Check node paths and property names'], undefined, { parseStdoutAsJson: true });
|
||||
}
|
||||
export async function handleGetNodeProperties(runner, args) {
|
||||
args = normalizeParameters(args);
|
||||
const v = validateSceneArgs(args);
|
||||
if ('isError' in v)
|
||||
return v;
|
||||
if (!args.nodes || !Array.isArray(args.nodes)) {
|
||||
return createErrorResponse('nodes array is required', [
|
||||
'Provide an array of { nodePath, changedOnly? }',
|
||||
]);
|
||||
}
|
||||
const params = { scenePath: args.scenePath, nodes: args.nodes };
|
||||
return executeSceneOp(runner, 'get_node_properties', params, v.projectPath, 'Failed to get node properties', ['Check node paths']);
|
||||
}
|
||||
export async function handleAttachScript(runner, args) {
|
||||
args = normalizeParameters(args);
|
||||
const v = validateSceneArgs(args);
|
||||
if ('isError' in v)
|
||||
return v;
|
||||
if (!args.nodePath || !validateNodePath(args.nodePath)) {
|
||||
return createErrorResponse('Valid nodePath is required', [
|
||||
'Provide the node path (e.g. "root/Player")',
|
||||
]);
|
||||
}
|
||||
if (!args.scriptPath || !validateSubPath(v.projectPath, args.scriptPath)) {
|
||||
return createErrorResponse('Valid scriptPath is required', [
|
||||
'Provide a relative script path that stays inside the project directory',
|
||||
]);
|
||||
}
|
||||
const scriptFullPath = join(v.projectPath, args.scriptPath);
|
||||
if (!existsSync(scriptFullPath)) {
|
||||
return createErrorResponse(`Script file does not exist: ${args.scriptPath}`, [
|
||||
'Create the script file first',
|
||||
]);
|
||||
}
|
||||
const params = {
|
||||
scenePath: args.scenePath,
|
||||
nodePath: args.nodePath,
|
||||
scriptPath: args.scriptPath,
|
||||
};
|
||||
return executeSceneOp(runner, 'attach_script', params, v.projectPath, 'Failed to attach script', ['Ensure the script is valid for this node type'], undefined, { parseStdoutAsJson: true });
|
||||
}
|
||||
export async function handleGetSceneTree(runner, args) {
|
||||
args = normalizeParameters(args);
|
||||
const v = validateSceneArgs(args);
|
||||
if ('isError' in v)
|
||||
return v;
|
||||
if (args.parentPath && !validateNodePath(args.parentPath)) {
|
||||
return createErrorResponse('Invalid parentPath', [
|
||||
'Provide a scene-tree path without ".." (e.g. "root/Player")',
|
||||
]);
|
||||
}
|
||||
const params = { scenePath: args.scenePath };
|
||||
if (args.parentPath)
|
||||
params.parentPath = args.parentPath;
|
||||
if (typeof args.maxDepth === 'number')
|
||||
params.maxDepth = args.maxDepth;
|
||||
return executeSceneOp(runner, 'get_scene_tree', params, v.projectPath, 'Failed to get scene tree', ['Ensure the scene is valid']);
|
||||
}
|
||||
export async function handleDuplicateNode(runner, args) {
|
||||
args = normalizeParameters(args);
|
||||
const v = validateSceneArgs(args);
|
||||
if ('isError' in v)
|
||||
return v;
|
||||
if (!args.nodePath || !validateNodePath(args.nodePath)) {
|
||||
return createErrorResponse('Valid nodePath is required', [
|
||||
'Provide the node path to duplicate',
|
||||
]);
|
||||
}
|
||||
if (args.targetParentPath && !validateNodePath(args.targetParentPath)) {
|
||||
return createErrorResponse('Invalid targetParentPath', [
|
||||
'Provide a scene-tree path without ".." (e.g. "root/Player")',
|
||||
]);
|
||||
}
|
||||
const params = { scenePath: args.scenePath, nodePath: args.nodePath };
|
||||
if (args.newName)
|
||||
params.newName = args.newName;
|
||||
if (args.targetParentPath)
|
||||
params.targetParentPath = args.targetParentPath;
|
||||
return executeSceneOp(runner, 'duplicate_node', params, v.projectPath, 'Failed to duplicate node', ['Check if the node path and target parent path are correct'], undefined, { parseStdoutAsJson: true });
|
||||
}
|
||||
export async function handleGetNodeSignals(runner, args) {
|
||||
args = normalizeParameters(args);
|
||||
const v = validateSceneArgs(args);
|
||||
if ('isError' in v)
|
||||
return v;
|
||||
if (!args.nodePath || !validateNodePath(args.nodePath)) {
|
||||
return createErrorResponse('Valid nodePath is required', [
|
||||
'Provide the node path (e.g. "root/Button")',
|
||||
]);
|
||||
}
|
||||
const params = { scenePath: args.scenePath, nodePath: args.nodePath };
|
||||
return executeSceneOp(runner, 'get_node_signals', params, v.projectPath, 'Failed to get node signals', ['Check if the node path is correct'], undefined, { parseStdoutAsJson: true });
|
||||
}
|
||||
function validateSignalArgs(args) {
|
||||
const v = validateSceneArgs(args);
|
||||
if ('isError' in v)
|
||||
return v;
|
||||
if (!args.nodePath || !validateNodePath(args.nodePath)) {
|
||||
return createErrorResponse('Valid nodePath is required', ['Provide the source node path']);
|
||||
}
|
||||
if (!args.signal || !args.targetNodePath || !args.method) {
|
||||
return createErrorResponse('signal, targetNodePath, and method are required', [
|
||||
'Provide all three parameters',
|
||||
]);
|
||||
}
|
||||
if (!validateNodePath(args.targetNodePath)) {
|
||||
return createErrorResponse('Invalid targetNodePath', [
|
||||
'Provide a scene-tree path without ".." (e.g. "root/Player")',
|
||||
]);
|
||||
}
|
||||
return {
|
||||
projectPath: v.projectPath,
|
||||
scenePath: v.scenePath,
|
||||
nodePath: args.nodePath,
|
||||
signal: args.signal,
|
||||
targetNodePath: args.targetNodePath,
|
||||
method: args.method,
|
||||
};
|
||||
}
|
||||
export async function handleConnectSignal(runner, args) {
|
||||
args = normalizeParameters(args);
|
||||
const v = validateSignalArgs(args);
|
||||
if ('isError' in v)
|
||||
return v;
|
||||
const params = {
|
||||
scenePath: v.scenePath,
|
||||
nodePath: v.nodePath,
|
||||
signal: v.signal,
|
||||
targetNodePath: v.targetNodePath,
|
||||
method: v.method,
|
||||
};
|
||||
return executeSceneOp(runner, 'connect_signal', params, v.projectPath, 'Failed to connect signal', ['Ensure the signal exists on the source node and the method exists on the target node']);
|
||||
}
|
||||
export async function handleDisconnectSignal(runner, args) {
|
||||
args = normalizeParameters(args);
|
||||
const v = validateSignalArgs(args);
|
||||
if ('isError' in v)
|
||||
return v;
|
||||
const params = {
|
||||
scenePath: v.scenePath,
|
||||
nodePath: v.nodePath,
|
||||
signal: v.signal,
|
||||
targetNodePath: v.targetNodePath,
|
||||
method: v.method,
|
||||
};
|
||||
return executeSceneOp(runner, 'disconnect_signal', params, v.projectPath, 'Failed to disconnect signal', ['Ensure the signal connection exists before trying to disconnect it']);
|
||||
}
|
||||
//# sourceMappingURL=node-tools.js.map
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
+65
@@ -0,0 +1,65 @@
|
||||
import type { GodotRunner, OperationParams, ToolDefinition } from '../utils/godot-runner.js';
|
||||
export declare const projectToolDefinitions: ToolDefinition[];
|
||||
export declare function handleListProjects(args: OperationParams): Promise<{
|
||||
content: Array<{
|
||||
type: "text";
|
||||
text: string;
|
||||
}>;
|
||||
isError: boolean;
|
||||
} | {
|
||||
content: {
|
||||
type: string;
|
||||
text: string;
|
||||
}[];
|
||||
}>;
|
||||
export declare function handleGetProjectInfo(runner: GodotRunner, args: OperationParams): Promise<{
|
||||
content: Array<{
|
||||
type: "text";
|
||||
text: string;
|
||||
}>;
|
||||
isError: boolean;
|
||||
} | {
|
||||
content: {
|
||||
type: string;
|
||||
text: string;
|
||||
}[];
|
||||
}>;
|
||||
export declare function handleGetProjectFiles(args: OperationParams): Promise<{
|
||||
content: Array<{
|
||||
type: "text";
|
||||
text: string;
|
||||
}>;
|
||||
isError: boolean;
|
||||
} | {
|
||||
content: {
|
||||
type: string;
|
||||
text: string;
|
||||
}[];
|
||||
}>;
|
||||
export declare function handleSearchProject(args: OperationParams): Promise<import("../utils/godot-runner.js").ToolResponse | {
|
||||
content: Array<{
|
||||
type: "text";
|
||||
text: string;
|
||||
}>;
|
||||
isError: boolean;
|
||||
}>;
|
||||
export declare function handleGetSceneDependencies(args: OperationParams): Promise<import("../utils/godot-runner.js").ToolResponse | {
|
||||
content: Array<{
|
||||
type: "text";
|
||||
text: string;
|
||||
}>;
|
||||
isError: boolean;
|
||||
}>;
|
||||
export declare function handleGetProjectSettings(args: OperationParams): Promise<{
|
||||
content: Array<{
|
||||
type: "text";
|
||||
text: string;
|
||||
}>;
|
||||
isError: boolean;
|
||||
} | {
|
||||
content: {
|
||||
type: string;
|
||||
text: string;
|
||||
}[];
|
||||
}>;
|
||||
//# sourceMappingURL=project-tools.d.ts.map
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"project-tools.d.ts","sourceRoot":"","sources":["../../src/tools/project-tools.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,WAAW,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAoB7F,eAAO,MAAM,sBAAsB,EAAE,cAAc,EAwJlD,CAAC;AAsPF,wBAAsB,kBAAkB,CAAC,IAAI,EAAE,eAAe;;;;;;;;;;;GAkC7D;AAED,wBAAsB,oBAAoB,CAAC,MAAM,EAAE,WAAW,EAAE,IAAI,EAAE,eAAe;;;;;;;;;;;GAkDpF;AAED,wBAAsB,qBAAqB,CAAC,IAAI,EAAE,eAAe;;;;;;;;;;;GAiBhE;AAED,wBAAsB,mBAAmB,CAAC,IAAI,EAAE,eAAe;;;;;;GA4B9D;AAED,wBAAsB,0BAA0B,CAAC,IAAI,EAAE,eAAe;;;;;;GAoDrE;AAED,wBAAsB,wBAAwB,CAAC,IAAI,EAAE,eAAe;;;;;;;;;;;GAkBnE"}
|
||||
Vendored
+544
@@ -0,0 +1,544 @@
|
||||
import { join, basename } from 'path';
|
||||
import { existsSync, readdirSync, readFileSync } from 'fs';
|
||||
import { normalizeParameters, validatePath, validateSubPath, validateProjectArgs, createErrorResponse, createStructuredResponse, getErrorMessage, projectGodotPath, } from '../utils/godot-runner.js';
|
||||
import { logDebug } from '../utils/logger.js';
|
||||
function fileExtension(name) {
|
||||
const dotIdx = name.lastIndexOf('.');
|
||||
return dotIdx >= 0 ? name.slice(dotIdx + 1).toLowerCase() : '';
|
||||
}
|
||||
// --- Tool definitions ---
|
||||
export const projectToolDefinitions = [
|
||||
{
|
||||
name: 'list_projects',
|
||||
description: 'Find Godot projects under a directory by locating project.godot files. Use to discover available projects when the user has not specified one; for inspecting a known project, use get_project_info. recursive:true descends into subdirectories (skipping hidden ones); default false checks only the directory itself and its immediate children. Returns: [{ path, name }], empty array on no matches.',
|
||||
annotations: { readOnlyHint: true },
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
directory: {
|
||||
type: 'string',
|
||||
description: 'Directory to search for Godot projects',
|
||||
},
|
||||
recursive: {
|
||||
type: 'boolean',
|
||||
description: 'Whether to search recursively (default: false)',
|
||||
},
|
||||
},
|
||||
required: ['directory'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_project_info',
|
||||
description: 'Get metadata about a Godot project: name, path, Godot version, and a structure summary (counts of scenes/scripts/assets/other). Omit projectPath to get just the Godot version (useful for capability checks). Returns: { name, path, godotVersion, structure } or { godotVersion } when projectPath is omitted. Errors if projectPath is set but lacks project.godot.',
|
||||
annotations: { readOnlyHint: true },
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
projectPath: {
|
||||
type: 'string',
|
||||
description: 'Path to the Godot project directory (optional — omit to get Godot version only)',
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_project_files',
|
||||
description: 'Return a recursive file tree of a Godot project. Use to discover project structure when paths are unknown. Pass extensions to filter (e.g. ["gd","tscn"]); maxDepth caps recursion (-1 unlimited). Skips hidden (dot-prefixed) entries and the .mcp directory. Returns: { name, type, path, extension?, children? } (nested tree).',
|
||||
annotations: { readOnlyHint: true },
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
projectPath: { type: 'string', description: 'Path to the Godot project directory' },
|
||||
maxDepth: {
|
||||
type: 'number',
|
||||
description: 'Maximum recursion depth. -1 means unlimited (default: -1)',
|
||||
},
|
||||
extensions: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'Filter to only these file extensions (e.g. ["gd", "tscn"]). Omit to include all.',
|
||||
},
|
||||
},
|
||||
required: ['projectPath'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'search_project',
|
||||
description: 'Plain-text (substring) search across project files. Use to find references, callers, or signatures across the codebase. Default fileTypes is ["gd","tscn","cs","gdshader"]; caseSensitive default false; maxResults default 100. Skips hidden entries and the .mcp directory. Returns: matches[] (project-relative file, 1-indexed lineNumber, line text) and truncated:true when maxResults was hit — consider raising it.',
|
||||
annotations: { readOnlyHint: true },
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
projectPath: { type: 'string', description: 'Path to the Godot project directory' },
|
||||
pattern: { type: 'string', description: 'Plain-text string to search for' },
|
||||
fileTypes: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'File extensions to search (default: ["gd", "tscn", "cs", "gdshader"])',
|
||||
},
|
||||
caseSensitive: { type: 'boolean', description: 'Case-sensitive search (default: false)' },
|
||||
maxResults: { type: 'number', description: 'Maximum matches to return (default: 100)' },
|
||||
},
|
||||
required: ['projectPath', 'pattern'],
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
matches: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
file: { type: 'string' },
|
||||
lineNumber: { type: 'number' },
|
||||
line: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
truncated: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_scene_dependencies',
|
||||
description: 'Parse a .tscn file for ext_resource references (scripts, textures, subscenes). Use to inspect what a scene depends on before refactoring or moving files. Returns: the queried scene path and dependencies[] from ext_resource refs (path, type, optional uid). Errors if scene file does not exist.',
|
||||
annotations: { readOnlyHint: true },
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
projectPath: { type: 'string', description: 'Path to the Godot project directory' },
|
||||
scenePath: {
|
||||
type: 'string',
|
||||
description: 'Path to the .tscn file relative to the project root (e.g. "scenes/main.tscn")',
|
||||
},
|
||||
},
|
||||
required: ['projectPath', 'scenePath'],
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
scene: { type: 'string' },
|
||||
dependencies: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string' },
|
||||
type: { type: 'string' },
|
||||
uid: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_project_settings',
|
||||
description: 'Parse project.godot into structured JSON. Use to inspect configured display, input, rendering, etc. settings without launching Godot. Pass section to filter to one INI section (e.g. "display", "application"). Returns: { settings: { [section]: { [key]: value } } } or { settings: { [key]: value } } when section is given. Complex Godot types are returned as raw strings; keys outside any section appear under __global__.',
|
||||
annotations: { readOnlyHint: true },
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
projectPath: { type: 'string', description: 'Path to the Godot project directory' },
|
||||
section: {
|
||||
type: 'string',
|
||||
description: 'Filter to a specific INI section (e.g. "display", "application"). Omit for all sections.',
|
||||
},
|
||||
},
|
||||
required: ['projectPath'],
|
||||
},
|
||||
},
|
||||
];
|
||||
// --- Helpers ---
|
||||
const PROJECT_SCAN_BLACKLIST = new Set(['.git', '.godot', '.mcp', 'node_modules', '.svn', '.hg']);
|
||||
function findGodotProjects(directory, recursive) {
|
||||
const projects = [];
|
||||
try {
|
||||
const projectFile = projectGodotPath(directory);
|
||||
if (existsSync(projectFile)) {
|
||||
projects.push({
|
||||
path: directory,
|
||||
name: basename(directory),
|
||||
});
|
||||
}
|
||||
const entries = readdirSync(directory, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || PROJECT_SCAN_BLACKLIST.has(entry.name))
|
||||
continue;
|
||||
const subdir = join(directory, entry.name);
|
||||
if (existsSync(projectGodotPath(subdir))) {
|
||||
projects.push({ path: subdir, name: entry.name });
|
||||
}
|
||||
else if (recursive) {
|
||||
projects.push(...findGodotProjects(subdir, true));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
logDebug(`Error searching directory ${directory}: ${error}`);
|
||||
}
|
||||
return projects;
|
||||
}
|
||||
function getProjectStructure(projectPath) {
|
||||
const structure = {
|
||||
scenes: 0,
|
||||
scripts: 0,
|
||||
assets: 0,
|
||||
other: 0,
|
||||
};
|
||||
const scanDirectory = (currentPath) => {
|
||||
try {
|
||||
const entries = readdirSync(currentPath, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const entryPath = join(currentPath, entry.name);
|
||||
if (entry.name.startsWith('.')) {
|
||||
continue;
|
||||
}
|
||||
if (entry.isDirectory()) {
|
||||
scanDirectory(entryPath);
|
||||
}
|
||||
else if (entry.isFile()) {
|
||||
const ext = fileExtension(entry.name);
|
||||
if (ext === 'tscn') {
|
||||
structure.scenes++;
|
||||
}
|
||||
else if (ext === 'gd' || ext === 'gdscript' || ext === 'cs') {
|
||||
structure.scripts++;
|
||||
}
|
||||
else if (['png', 'jpg', 'jpeg', 'webp', 'svg', 'ttf', 'wav', 'mp3', 'ogg'].includes(ext || '')) {
|
||||
structure.assets++;
|
||||
}
|
||||
else {
|
||||
structure.other++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
logDebug(`Error scanning directory ${currentPath}: ${error}`);
|
||||
}
|
||||
};
|
||||
scanDirectory(projectPath);
|
||||
return structure;
|
||||
}
|
||||
function buildFilesystemTree(currentPath, relativePath, maxDepth, currentDepth, extensions) {
|
||||
const name = basename(currentPath);
|
||||
const node = { name, type: 'dir', path: relativePath || '.' };
|
||||
if (maxDepth !== -1 && currentDepth >= maxDepth) {
|
||||
node.children = [];
|
||||
return node;
|
||||
}
|
||||
const children = [];
|
||||
try {
|
||||
const entries = readdirSync(currentPath, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith('.'))
|
||||
continue;
|
||||
const childRelPath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
|
||||
if (entry.isDirectory()) {
|
||||
children.push(buildFilesystemTree(join(currentPath, entry.name), childRelPath, maxDepth, currentDepth + 1, extensions));
|
||||
}
|
||||
else if (entry.isFile()) {
|
||||
const ext = fileExtension(entry.name);
|
||||
if (extensions && !extensions.includes(ext))
|
||||
continue;
|
||||
children.push({ name: entry.name, type: 'file', path: childRelPath, extension: ext });
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
logDebug(`buildFilesystemTree error at ${currentPath}: ${err}`);
|
||||
}
|
||||
node.children = children;
|
||||
return node;
|
||||
}
|
||||
function searchInFiles(rootPath, pattern, fileTypes, caseSensitive, maxResults) {
|
||||
const matches = [];
|
||||
let truncated = false;
|
||||
const searchDir = (currentPath, relBase) => {
|
||||
if (truncated)
|
||||
return;
|
||||
let entries;
|
||||
try {
|
||||
entries = readdirSync(currentPath, { withFileTypes: true });
|
||||
}
|
||||
catch (err) {
|
||||
logDebug(`searchInFiles readdir error at ${currentPath}: ${err}`);
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (truncated)
|
||||
return;
|
||||
if (entry.name.startsWith('.'))
|
||||
continue;
|
||||
const childRelPath = relBase ? `${relBase}/${entry.name}` : entry.name;
|
||||
const fullPath = join(currentPath, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
searchDir(fullPath, childRelPath);
|
||||
}
|
||||
else if (entry.isFile()) {
|
||||
const ext = fileExtension(entry.name);
|
||||
if (!fileTypes.includes(ext))
|
||||
continue;
|
||||
let content;
|
||||
try {
|
||||
content = readFileSync(fullPath, 'utf8');
|
||||
}
|
||||
catch {
|
||||
continue;
|
||||
}
|
||||
const lines = content.split('\n');
|
||||
const needle = caseSensitive ? pattern : pattern.toLowerCase();
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const haystack = caseSensitive ? lines[i] : lines[i].toLowerCase();
|
||||
if (haystack.includes(needle)) {
|
||||
matches.push({ file: childRelPath, lineNumber: i + 1, line: lines[i] });
|
||||
if (matches.length >= maxResults) {
|
||||
truncated = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
searchDir(rootPath, '');
|
||||
return { matches, truncated };
|
||||
}
|
||||
function parseProjectSettings(projectFilePath) {
|
||||
const content = readFileSync(projectFilePath, 'utf8');
|
||||
const result = {};
|
||||
let currentSection = '__global__';
|
||||
for (const rawLine of content.split('\n')) {
|
||||
const line = rawLine.trim();
|
||||
if (line === '' || line.startsWith(';') || line.startsWith('#'))
|
||||
continue;
|
||||
if (line.startsWith('config_version'))
|
||||
continue; // header line
|
||||
if (line.startsWith('[') && line.endsWith(']')) {
|
||||
currentSection = line.slice(1, -1);
|
||||
continue;
|
||||
}
|
||||
const eqIdx = line.indexOf('=');
|
||||
if (eqIdx === -1)
|
||||
continue;
|
||||
const key = line.slice(0, eqIdx).trim();
|
||||
const rawVal = line.slice(eqIdx + 1).trim();
|
||||
let value;
|
||||
if (rawVal.startsWith('"') && rawVal.endsWith('"')) {
|
||||
value = rawVal.slice(1, -1);
|
||||
}
|
||||
else if (rawVal === 'true') {
|
||||
value = true;
|
||||
}
|
||||
else if (rawVal === 'false') {
|
||||
value = false;
|
||||
}
|
||||
else {
|
||||
const num = Number(rawVal);
|
||||
value = isNaN(num) ? rawVal : num;
|
||||
}
|
||||
if (!result[currentSection])
|
||||
result[currentSection] = {};
|
||||
result[currentSection][key] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
// --- Handlers ---
|
||||
export async function handleListProjects(args) {
|
||||
args = normalizeParameters(args);
|
||||
if (!args.directory) {
|
||||
return createErrorResponse('Directory is required', [
|
||||
'Provide a valid directory path to search for Godot projects',
|
||||
]);
|
||||
}
|
||||
if (!validatePath(args.directory)) {
|
||||
return createErrorResponse('Invalid directory path', [
|
||||
'Provide a valid path without ".." or other potentially unsafe characters',
|
||||
]);
|
||||
}
|
||||
try {
|
||||
if (!existsSync(args.directory)) {
|
||||
return createErrorResponse(`Directory does not exist: ${args.directory}`, [
|
||||
'Provide a valid directory path that exists on the system',
|
||||
]);
|
||||
}
|
||||
const recursive = args.recursive === true;
|
||||
const projects = findGodotProjects(args.directory, recursive);
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify(projects) }],
|
||||
};
|
||||
}
|
||||
catch (error) {
|
||||
return createErrorResponse(`Failed to list projects: ${getErrorMessage(error)}`, [
|
||||
'Ensure the directory exists and is accessible',
|
||||
'Check if you have permission to read the directory',
|
||||
]);
|
||||
}
|
||||
}
|
||||
export async function handleGetProjectInfo(runner, args) {
|
||||
args = normalizeParameters(args);
|
||||
try {
|
||||
const version = await runner.getVersion();
|
||||
// If no project path, return just the Godot version
|
||||
if (!args.projectPath) {
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify({ godotVersion: version }) }],
|
||||
};
|
||||
}
|
||||
const v = validateProjectArgs(args);
|
||||
if ('isError' in v)
|
||||
return v;
|
||||
const projectFile = projectGodotPath(v.projectPath);
|
||||
const projectStructure = getProjectStructure(v.projectPath);
|
||||
let projectName = basename(v.projectPath);
|
||||
try {
|
||||
const projectFileContent = readFileSync(projectFile, 'utf8');
|
||||
const configNameMatch = projectFileContent.match(/config\/name="([^"]+)"/);
|
||||
if (configNameMatch && configNameMatch[1]) {
|
||||
projectName = configNameMatch[1];
|
||||
logDebug(`Found project name in config: ${projectName}`);
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
logDebug(`Error reading project file: ${error}`);
|
||||
}
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
name: projectName,
|
||||
path: v.projectPath,
|
||||
godotVersion: version,
|
||||
structure: projectStructure,
|
||||
}),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
catch (error) {
|
||||
return createErrorResponse(`Failed to get project info: ${getErrorMessage(error)}`, [
|
||||
'Ensure Godot is installed correctly',
|
||||
'Check if the GODOT_PATH environment variable is set correctly',
|
||||
]);
|
||||
}
|
||||
}
|
||||
export async function handleGetProjectFiles(args) {
|
||||
args = normalizeParameters(args);
|
||||
const v = validateProjectArgs(args);
|
||||
if ('isError' in v)
|
||||
return v;
|
||||
try {
|
||||
const maxDepth = typeof args.maxDepth === 'number' ? args.maxDepth : -1;
|
||||
const extensions = Array.isArray(args.extensions)
|
||||
? args.extensions.map((e) => e.toLowerCase().replace(/^\./, ''))
|
||||
: null;
|
||||
const tree = buildFilesystemTree(v.projectPath, '', maxDepth, 0, extensions);
|
||||
return { content: [{ type: 'text', text: JSON.stringify(tree) }] };
|
||||
}
|
||||
catch (error) {
|
||||
return createErrorResponse(`Failed to get project files: ${getErrorMessage(error)}`, [
|
||||
'Check if the project directory is accessible',
|
||||
]);
|
||||
}
|
||||
}
|
||||
export async function handleSearchProject(args) {
|
||||
args = normalizeParameters(args);
|
||||
const v = validateProjectArgs(args);
|
||||
if ('isError' in v)
|
||||
return v;
|
||||
if (!args.pattern || typeof args.pattern !== 'string') {
|
||||
return createErrorResponse('pattern is required', ['Provide a plain-text search string']);
|
||||
}
|
||||
try {
|
||||
const fileTypes = Array.isArray(args.fileTypes)
|
||||
? args.fileTypes.map((e) => e.toLowerCase().replace(/^\./, ''))
|
||||
: ['gd', 'tscn', 'cs', 'gdshader'];
|
||||
const caseSensitive = args.caseSensitive === true;
|
||||
const maxResults = typeof args.maxResults === 'number' ? args.maxResults : 100;
|
||||
const result = searchInFiles(v.projectPath, args.pattern, fileTypes, caseSensitive, maxResults);
|
||||
return createStructuredResponse(result);
|
||||
}
|
||||
catch (error) {
|
||||
return createErrorResponse(`Failed to search project: ${getErrorMessage(error)}`, [
|
||||
'Check if the project directory is accessible',
|
||||
]);
|
||||
}
|
||||
}
|
||||
export async function handleGetSceneDependencies(args) {
|
||||
args = normalizeParameters(args);
|
||||
const v = validateProjectArgs(args);
|
||||
if ('isError' in v)
|
||||
return v;
|
||||
if (!args.scenePath || typeof args.scenePath !== 'string') {
|
||||
return createErrorResponse('scenePath is required', [
|
||||
'Provide a path relative to the project root, e.g. "scenes/main.tscn"',
|
||||
]);
|
||||
}
|
||||
if (!validateSubPath(v.projectPath, args.scenePath)) {
|
||||
return createErrorResponse('Invalid scenePath', [
|
||||
'Provide a valid relative path without ".." that stays inside the project directory',
|
||||
]);
|
||||
}
|
||||
try {
|
||||
const sceneFullPath = join(v.projectPath, args.scenePath);
|
||||
if (!existsSync(sceneFullPath)) {
|
||||
return createErrorResponse(`Scene file not found: ${args.scenePath}`, [
|
||||
'Verify the path is relative to the project root',
|
||||
'Use get_project_files to list available .tscn files',
|
||||
]);
|
||||
}
|
||||
const sceneContent = readFileSync(sceneFullPath, 'utf8');
|
||||
const dependencies = [];
|
||||
const extResourcePattern = /^\[ext_resource([^\]]*)\]/gm;
|
||||
let match;
|
||||
while ((match = extResourcePattern.exec(sceneContent)) !== null) {
|
||||
const attrs = match[1];
|
||||
const typeMatch = attrs.match(/\btype="([^"]*)"/);
|
||||
const pathMatch = attrs.match(/\bpath="([^"]*)"/);
|
||||
const uidMatch = attrs.match(/\buid="([^"]*)"/);
|
||||
if (pathMatch) {
|
||||
const depPath = pathMatch[1].replace(/^res:\/\//, '');
|
||||
const dep = {
|
||||
path: depPath,
|
||||
type: typeMatch ? typeMatch[1] : 'Unknown',
|
||||
};
|
||||
if (uidMatch)
|
||||
dep.uid = uidMatch[1];
|
||||
dependencies.push(dep);
|
||||
}
|
||||
}
|
||||
return createStructuredResponse({
|
||||
scene: args.scenePath,
|
||||
dependencies,
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
return createErrorResponse(`Failed to get scene dependencies: ${getErrorMessage(error)}`, [
|
||||
'Check if the scene file is accessible',
|
||||
]);
|
||||
}
|
||||
}
|
||||
export async function handleGetProjectSettings(args) {
|
||||
args = normalizeParameters(args);
|
||||
const v = validateProjectArgs(args);
|
||||
if ('isError' in v)
|
||||
return v;
|
||||
try {
|
||||
const projectFile = projectGodotPath(v.projectPath);
|
||||
const allSettings = parseProjectSettings(projectFile);
|
||||
if (args.section && typeof args.section === 'string') {
|
||||
const sectionData = allSettings[args.section] ?? {};
|
||||
return { content: [{ type: 'text', text: JSON.stringify({ settings: sectionData }) }] };
|
||||
}
|
||||
return { content: [{ type: 'text', text: JSON.stringify({ settings: allSettings }) }] };
|
||||
}
|
||||
catch (error) {
|
||||
return createErrorResponse(`Failed to get project settings: ${getErrorMessage(error)}`, [
|
||||
'Check if project.godot is accessible',
|
||||
]);
|
||||
}
|
||||
}
|
||||
//# sourceMappingURL=project-tools.js.map
|
||||
+1
File diff suppressed because one or more lines are too long
+88
@@ -0,0 +1,88 @@
|
||||
import type { GodotRunner, OperationParams, ToolDefinition } from '../utils/godot-runner.js';
|
||||
export declare const runtimeToolDefinitions: ToolDefinition[];
|
||||
export declare function handleLaunchEditor(runner: GodotRunner, args: OperationParams): Promise<{
|
||||
content: Array<{
|
||||
type: "text";
|
||||
text: string;
|
||||
}>;
|
||||
isError: boolean;
|
||||
} | {
|
||||
content: {
|
||||
type: string;
|
||||
text: string;
|
||||
}[];
|
||||
}>;
|
||||
export declare function handleRunProject(runner: GodotRunner, args: OperationParams): Promise<{
|
||||
content: Array<{
|
||||
type: "text";
|
||||
text: string;
|
||||
}>;
|
||||
isError: boolean;
|
||||
} | {
|
||||
content: {
|
||||
type: string;
|
||||
text: string;
|
||||
}[];
|
||||
}>;
|
||||
export declare function handleAttachProject(runner: GodotRunner, args: OperationParams): Promise<{
|
||||
content: Array<{
|
||||
type: "text";
|
||||
text: string;
|
||||
}>;
|
||||
isError: boolean;
|
||||
} | {
|
||||
content: {
|
||||
type: string;
|
||||
text: string;
|
||||
}[];
|
||||
}>;
|
||||
export declare function handleDetachProject(runner: GodotRunner): Promise<import("../utils/godot-runner.js").ToolResponse | {
|
||||
content: Array<{
|
||||
type: "text";
|
||||
text: string;
|
||||
}>;
|
||||
isError: boolean;
|
||||
}>;
|
||||
export declare function handleGetDebugOutput(runner: GodotRunner, args?: OperationParams): import("../utils/godot-runner.js").ToolResponse | {
|
||||
content: Array<{
|
||||
type: "text";
|
||||
text: string;
|
||||
}>;
|
||||
isError: boolean;
|
||||
};
|
||||
export declare function handleStopProject(runner: GodotRunner): Promise<import("../utils/godot-runner.js").ToolResponse | {
|
||||
content: Array<{
|
||||
type: "text";
|
||||
text: string;
|
||||
}>;
|
||||
isError: boolean;
|
||||
}>;
|
||||
export declare function handleTakeScreenshot(runner: GodotRunner, args: OperationParams): Promise<import("../utils/godot-runner.js").ToolResponse | {
|
||||
content: Array<{
|
||||
type: "text";
|
||||
text: string;
|
||||
}>;
|
||||
isError: boolean;
|
||||
}>;
|
||||
export declare function handleSimulateInput(runner: GodotRunner, args: OperationParams): Promise<import("../utils/godot-runner.js").ToolResponse | {
|
||||
content: Array<{
|
||||
type: "text";
|
||||
text: string;
|
||||
}>;
|
||||
isError: boolean;
|
||||
}>;
|
||||
export declare function handleGetUiElements(runner: GodotRunner, args: OperationParams): Promise<import("../utils/godot-runner.js").ToolResponse | {
|
||||
content: Array<{
|
||||
type: "text";
|
||||
text: string;
|
||||
}>;
|
||||
isError: boolean;
|
||||
}>;
|
||||
export declare function handleRunScript(runner: GodotRunner, args: OperationParams): Promise<import("../utils/godot-runner.js").ToolResponse | {
|
||||
content: Array<{
|
||||
type: "text";
|
||||
text: string;
|
||||
}>;
|
||||
isError: boolean;
|
||||
}>;
|
||||
//# sourceMappingURL=runtime-tools.d.ts.map
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"runtime-tools.d.ts","sourceRoot":"","sources":["../../src/tools/runtime-tools.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,WAAW,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAqC7F,eAAO,MAAM,sBAAsB,EAAE,cAAc,EA2XlD,CAAC;AAiCF,wBAAsB,kBAAkB,CAAC,MAAM,EAAE,WAAW,EAAE,IAAI,EAAE,eAAe;;;;;;;;;;;GAsClF;AAED,wBAAsB,gBAAgB,CAAC,MAAM,EAAE,WAAW,EAAE,IAAI,EAAE,eAAe;;;;;;;;;;;GA+HhF;AAED,wBAAsB,mBAAmB,CAAC,MAAM,EAAE,WAAW,EAAE,IAAI,EAAE,eAAe;;;;;;;;;;;GA0EnF;AAED,wBAAsB,mBAAmB,CAAC,MAAM,EAAE,WAAW;;;;;;GAc5D;AAED,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,WAAW,EAAE,IAAI,GAAE,eAAoB;;;;;;EAgDnF;AAED,wBAAsB,iBAAiB,CAAC,MAAM,EAAE,WAAW;;;;;;GAoB1D;AAoBD,wBAAsB,oBAAoB,CAAC,MAAM,EAAE,WAAW,EAAE,IAAI,EAAE,eAAe;;;;;;GAyIpF;AAED,wBAAsB,mBAAmB,CAAC,MAAM,EAAE,WAAW,EAAE,IAAI,EAAE,eAAe;;;;;;GAiEnF;AAED,wBAAsB,mBAAmB,CAAC,MAAM,EAAE,WAAW,EAAE,IAAI,EAAE,eAAe;;;;;;GA4CnF;AAED,wBAAsB,eAAe,CAAC,MAAM,EAAE,WAAW,EAAE,IAAI,EAAE,eAAe;;;;;;GAkG/E"}
|
||||
Vendored
+948
@@ -0,0 +1,948 @@
|
||||
import { join, sep, resolve } from 'path';
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
||||
import { normalizeParameters, validateProjectArgs, validateSubPath, createErrorResponse, createStructuredResponse, getErrorMessage, isUnderDir, BRIDGE_WAIT_SPAWNED_TIMEOUT_MS, } from '../utils/godot-runner.js';
|
||||
import { attachRuntimeWarnings, parseBridgeJson, MAX_RUNTIME_ERROR_CONTEXT_LINES, } from '../utils/handler-helpers.js';
|
||||
import { logDebug } from '../utils/logger.js';
|
||||
import { randomUUID } from 'crypto';
|
||||
const SCREENSHOT_RESPONSE_MODES = ['full', 'preview', 'path_only'];
|
||||
const DEFAULT_PREVIEW_MAX_WIDTH = 960;
|
||||
const DEFAULT_PREVIEW_MAX_HEIGHT = 540;
|
||||
// --- Tool definitions ---
|
||||
export const runtimeToolDefinitions = [
|
||||
{
|
||||
name: 'launch_editor',
|
||||
description: 'Open the Godot editor GUI for a project for the human user. Use only when the user explicitly asks to "open the editor"; for any agent-driven work, use the headless scene/node tools (add_node, set_node_properties, etc.) instead — the editor cannot be controlled programmatically. Returns plain-text confirmation after spawning the editor process. Errors if projectPath has no project.godot.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
projectPath: {
|
||||
type: 'string',
|
||||
description: 'Path to the Godot project directory',
|
||||
},
|
||||
},
|
||||
required: ['projectPath'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'run_project',
|
||||
description: 'Spawn a Godot project as a child process with stdout/stderr captured. Required before take_screenshot, simulate_input, get_ui_elements, run_script, or get_debug_output. For a Godot process you launched yourself, use attach_project instead. Verifies MCP bridge readiness before returning success. Returns plain-text status with the assigned bridge port. Call stop_project when done. Errors if projectPath is not a Godot project or another session is already active.',
|
||||
annotations: { destructiveHint: true },
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
projectPath: {
|
||||
type: 'string',
|
||||
description: 'Path to the Godot project directory',
|
||||
},
|
||||
scene: {
|
||||
type: 'string',
|
||||
description: 'Scene to run (path relative to project, e.g. "scenes/main.tscn"). Omit to use the project\'s main scene.',
|
||||
},
|
||||
background: {
|
||||
type: 'boolean',
|
||||
description: 'If true, hides the Godot window off-screen and blocks all physical keyboard and mouse input, while keeping programmatic input (simulate_input, run_script) and screenshots fully active. Useful for automated agent-driven testing where the window should not be visible or interactive.',
|
||||
},
|
||||
bridgePort: {
|
||||
type: 'number',
|
||||
minimum: 1,
|
||||
maximum: 65535,
|
||||
description: "TCP port for the MCP bridge. Omit to auto-select a free port (recommended). The chosen port is baked into the project's `mcp_bridge.gd` at inject time, so the running Godot listens on exactly this port.",
|
||||
},
|
||||
},
|
||||
required: ['projectPath'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'attach_project',
|
||||
description: 'Inject the MCP bridge into a Godot process you launch yourself, then wait up to 15s for the bridge to respond. Call BEFORE Godot launches — Godot reads autoloads only at process start, so a late call returns "bridge did not respond." Recommended pattern: kick off the Godot launch in parallel with this call so the wait absorbs startup. Prefer run_project unless MCP must not spawn Godot. Returns plain-text status with the resolved bridge port. Call detach_project or stop_project when done.',
|
||||
annotations: { destructiveHint: true },
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
projectPath: {
|
||||
type: 'string',
|
||||
description: 'Path to the Godot project directory',
|
||||
},
|
||||
bridgePort: {
|
||||
type: 'number',
|
||||
minimum: 1,
|
||||
maximum: 65535,
|
||||
description: "TCP port for the MCP bridge. Omit to auto-select a free port (recommended). The chosen port is baked into the project's `mcp_bridge.gd` at inject time, so the running Godot listens on exactly this port.",
|
||||
},
|
||||
},
|
||||
required: ['projectPath'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'detach_project',
|
||||
description: 'Clear attached-mode runtime state and remove the injected McpBridge autoload. Does NOT stop the manually launched Godot process — that stays running. Use after attach_project when you are done driving the game from MCP. For spawned sessions (run_project), use stop_project instead. Returns: message confirming detach plus externalProcessPreserved (always true here — that is the point of detach vs stop_project). Errors if called outside an attached session.',
|
||||
annotations: { destructiveHint: true },
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
required: [],
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
message: { type: 'string' },
|
||||
externalProcessPreserved: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_debug_output',
|
||||
description: 'Get captured stdout/stderr from a spawned Godot project. Use whenever runtime tools fail unexpectedly — script errors, missing nodes, and crash backtraces all surface here. Requires run_project (not attach_project; attached mode does not capture output). Returns: output/errors (last `limit` lines each, default 200), running (false after exit, null when attached), exitCode after exit, attached:true with empty arrays in attached mode.',
|
||||
annotations: { readOnlyHint: true },
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
limit: {
|
||||
type: 'number',
|
||||
description: 'Max lines to return (default: 200, from end of output)',
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
output: { type: 'array', items: { type: 'string' } },
|
||||
errors: { type: 'array', items: { type: 'string' } },
|
||||
running: { type: ['boolean', 'null'] },
|
||||
exitCode: { type: ['number', 'null'] },
|
||||
attached: { type: 'boolean' },
|
||||
tip: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'stop_project',
|
||||
description: 'Stop the spawned Godot project and clean up MCP bridge state. Always call when done with runtime testing — even after a crash — to free the single process slot so run_project can be called again. For attached sessions, this detaches without killing the externally launched process. Returns: message, mode ("spawned"/"attached"), externalProcessPreserved (true only for attached), finalOutput and finalErrors (last 200 lines each). Errors if no session is active.',
|
||||
annotations: { destructiveHint: true },
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
required: [],
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
message: { type: 'string' },
|
||||
mode: { type: 'string' },
|
||||
externalProcessPreserved: { type: 'boolean' },
|
||||
finalOutput: { type: 'array', items: { type: 'string' } },
|
||||
finalErrors: { type: 'array', items: { type: 'string' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'take_screenshot',
|
||||
description: 'Capture a PNG of the running viewport. responseMode: preview (default — saves full PNG, returns bounded inline preview at 960x540), full (full inline PNG; use for small text or pixel-level inspection), path_only (saved-path only, no inline image). Saved under .mcp/screenshots. Returns: inline image block (full/preview modes), plus path and size of the saved PNG; previewPath/previewSize in preview mode; warnings for non-fatal runtime errors. Errors if no session or bridge times out (default 10000ms).',
|
||||
annotations: { readOnlyHint: true },
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
timeout: {
|
||||
type: 'number',
|
||||
description: 'Timeout in milliseconds to wait for the screenshot (default: 10000)',
|
||||
},
|
||||
responseMode: {
|
||||
type: 'string',
|
||||
enum: ['full', 'preview', 'path_only'],
|
||||
description: 'Response payload mode. "preview" returns a bounded inline preview plus paths (default). "full" returns the full inline PNG. "path_only" returns paths only.',
|
||||
},
|
||||
previewMaxWidth: {
|
||||
type: 'number',
|
||||
description: 'Maximum preview width in pixels when responseMode is "preview" (default: 960)',
|
||||
},
|
||||
previewMaxHeight: {
|
||||
type: 'number',
|
||||
description: 'Maximum preview height in pixels when responseMode is "preview" (default: 540)',
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
// The handler also emits an inline `image` content block for full/preview modes;
|
||||
// outputSchema only describes the structured JSON text payload per MCP spec.
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
responseMode: { type: 'string' },
|
||||
path: { type: 'string' },
|
||||
size: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
width: { type: 'number' },
|
||||
height: { type: 'number' },
|
||||
},
|
||||
},
|
||||
previewPath: { type: 'string' },
|
||||
previewSize: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
width: { type: 'number' },
|
||||
height: { type: 'number' },
|
||||
},
|
||||
},
|
||||
warnings: { type: 'array', items: { type: 'string' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'simulate_input',
|
||||
description: "Simulate sequential input in a running project. Each action's `type` (key, mouse_button, mouse_motion, click_element, action, wait) gates which other fields apply — see per-property docs. For click_element use get_ui_elements first; resolution is by path/name, not visible text. Press/release require two actions; insert wait between for frame ticks. Returns: success, actions_processed, warnings for runtime errors fired by input handlers. Errors if no session or any action fails validation.",
|
||||
annotations: { destructiveHint: true },
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
actions: {
|
||||
type: 'array',
|
||||
description: 'Array of input actions to execute sequentially. Each object must have a "type" field.',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
type: {
|
||||
type: 'string',
|
||||
enum: ['key', 'mouse_button', 'mouse_motion', 'click_element', 'action', 'wait'],
|
||||
description: 'The type of input action',
|
||||
},
|
||||
key: {
|
||||
type: 'string',
|
||||
description: '[key] Godot KEY_* constant name without the prefix (e.g. "W", "Space", "Escape", "Enter", "Tab", "Up", "PageUp"). Errors on unrecognized names.',
|
||||
},
|
||||
pressed: {
|
||||
type: 'boolean',
|
||||
description: '[key, mouse_button, action] Whether the input is pressed (true) or released (false). For mouse_button: omit to auto-click (press+release in one action); set explicitly only for hold/release. For key: defaults to true and does NOT auto-release — emit a second action with pressed:false to release.',
|
||||
},
|
||||
shift: { type: 'boolean', description: '[key] Shift modifier' },
|
||||
ctrl: { type: 'boolean', description: '[key] Ctrl modifier' },
|
||||
alt: { type: 'boolean', description: '[key] Alt modifier' },
|
||||
unicode: {
|
||||
type: 'number',
|
||||
description: '[key] Unicode codepoint for text-entry Controls (LineEdit, TextEdit). Auto-derived for ASCII letters/digits (respecting shift); pass explicitly for symbols or non-ASCII. E.g. 33 for "!", 64 for "@".',
|
||||
},
|
||||
button: {
|
||||
type: 'string',
|
||||
enum: ['left', 'right', 'middle'],
|
||||
description: '[mouse_button, click_element] Mouse button (default: left)',
|
||||
},
|
||||
x: {
|
||||
type: 'number',
|
||||
description: '[mouse_button, mouse_motion] X position in viewport pixels (0,0 = top-left)',
|
||||
},
|
||||
y: {
|
||||
type: 'number',
|
||||
description: '[mouse_button, mouse_motion] Y position in viewport pixels (0,0 = top-left)',
|
||||
},
|
||||
relative_x: {
|
||||
type: 'number',
|
||||
description: '[mouse_motion] Relative X movement in pixels',
|
||||
},
|
||||
relative_y: {
|
||||
type: 'number',
|
||||
description: '[mouse_motion] Relative Y movement in pixels',
|
||||
},
|
||||
double_click: {
|
||||
type: 'boolean',
|
||||
description: '[mouse_button, click_element] Double click',
|
||||
},
|
||||
element: {
|
||||
type: 'string',
|
||||
description: '[click_element] Identifies the UI element to click. Accepts: absolute node path (e.g. "/root/HUD/Button"), relative node path, or node name (BFS matched). Use get_ui_elements to discover valid names and paths.',
|
||||
},
|
||||
action: {
|
||||
type: 'string',
|
||||
description: '[action] Godot input action name (as defined in Project Settings > Input Map)',
|
||||
},
|
||||
strength: {
|
||||
type: 'number',
|
||||
description: '[action] Action strength (0–1, default 1.0)',
|
||||
},
|
||||
ms: {
|
||||
type: 'number',
|
||||
description: '[wait] Duration in milliseconds to pause before the next action (~16ms = one frame at 60fps).',
|
||||
},
|
||||
},
|
||||
required: ['type'],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['actions'],
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean' },
|
||||
actions_processed: { type: 'number' },
|
||||
warnings: { type: 'array', items: { type: 'string' } },
|
||||
tip: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_ui_elements',
|
||||
description: 'Walk the running scene tree and return all Control nodes with positions, sizes, types, and text content. Always call this before simulate_input click_element actions to discover valid element names and paths. Requires an active runtime session (run_project or attach_project). visibleOnly defaults true; pass false to include hidden Controls. filter narrows by class. Returns: elements[] with path/type/rect/visible plus optional text/disabled/tooltip.',
|
||||
annotations: { readOnlyHint: true },
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
visibleOnly: {
|
||||
type: 'boolean',
|
||||
description: 'Only return nodes where Control.visible is true (default: true). Set false to include hidden elements.',
|
||||
},
|
||||
filter: {
|
||||
type: 'string',
|
||||
description: 'Filter by Control node type (e.g. "Button", "Label", "LineEdit")',
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
elements: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
path: { type: 'string' },
|
||||
type: { type: 'string' },
|
||||
rect: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
x: { type: 'number' },
|
||||
y: { type: 'number' },
|
||||
width: { type: 'number' },
|
||||
height: { type: 'number' },
|
||||
},
|
||||
},
|
||||
visible: { type: 'boolean' },
|
||||
text: { type: 'string' },
|
||||
placeholder: { type: 'string' },
|
||||
disabled: { type: 'boolean' },
|
||||
tooltip: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
warnings: { type: 'array', items: { type: 'string' } },
|
||||
tip: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'run_script',
|
||||
description: 'Execute a custom GDScript in the live running project with full scene tree access. Requires an active runtime session. Script must extend RefCounted and define func execute(scene_tree: SceneTree) -> Variant. Return values are JSON-serialized (primitives, Vector2/3, Color, Dictionary, Array, and Node path strings). Use print() for debug output — it appears in get_debug_output, not in the result. In spawned mode, stderr runtime errors escalate to errors (when the script returns null) or surface as warnings. Returns: { success, result, warnings?, tip? } where result is the JSON-serialized return value of execute().',
|
||||
annotations: { destructiveHint: true },
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
script: {
|
||||
type: 'string',
|
||||
description: 'GDScript source code. Must contain "extends RefCounted" and "func execute(scene_tree: SceneTree) -> Variant".',
|
||||
},
|
||||
timeout: {
|
||||
type: 'number',
|
||||
description: 'Timeout in ms (default: 30000). Increase for long-running scripts.',
|
||||
},
|
||||
},
|
||||
required: ['script'],
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean' },
|
||||
result: {},
|
||||
warnings: { type: 'array', items: { type: 'string' } },
|
||||
tip: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
// --- Helpers ---
|
||||
function ensureRuntimeSession(runner, actionDescription) {
|
||||
if (!runner.activeSessionMode || !runner.activeProjectPath) {
|
||||
return createErrorResponse(`No active runtime session. A project must be running or attached to ${actionDescription}.`, [
|
||||
'Use run_project to start a Godot project first',
|
||||
'Or use attach_project before launching Godot manually',
|
||||
]);
|
||||
}
|
||||
if (runner.activeSessionMode === 'spawned' &&
|
||||
(!runner.activeProcess || runner.activeProcess.hasExited)) {
|
||||
return createErrorResponse(`The spawned Godot process has exited and cannot ${actionDescription}.`, [
|
||||
'Use get_debug_output to inspect the last captured logs',
|
||||
'Call stop_project to clean up, then run_project again',
|
||||
]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// --- Handlers ---
|
||||
export async function handleLaunchEditor(runner, args) {
|
||||
args = normalizeParameters(args);
|
||||
const v = validateProjectArgs(args);
|
||||
if ('isError' in v)
|
||||
return v;
|
||||
try {
|
||||
if (!runner.getGodotPath()) {
|
||||
await runner.detectGodotPath();
|
||||
if (!runner.getGodotPath()) {
|
||||
return createErrorResponse('Could not find a valid Godot executable path', [
|
||||
'Ensure Godot is installed correctly',
|
||||
'Set GODOT_PATH environment variable',
|
||||
]);
|
||||
}
|
||||
}
|
||||
logDebug(`Launching Godot editor for project: ${v.projectPath}`);
|
||||
const process = runner.launchEditor(v.projectPath);
|
||||
process.on('error', (err) => {
|
||||
console.error('Failed to start Godot editor:', err);
|
||||
});
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Godot editor launched successfully for project at ${v.projectPath}.\nNote: the editor is a GUI application and cannot be controlled programmatically. Use the scene and node editing tools (add_node, set_node_properties, etc.) to modify the project headlessly without the editor.`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
catch (error) {
|
||||
return createErrorResponse(`Failed to launch Godot editor: ${getErrorMessage(error)}`, [
|
||||
'Ensure Godot is installed correctly',
|
||||
'Check if the GODOT_PATH environment variable is set correctly',
|
||||
]);
|
||||
}
|
||||
}
|
||||
export async function handleRunProject(runner, args) {
|
||||
args = normalizeParameters(args);
|
||||
const v = validateProjectArgs(args);
|
||||
if ('isError' in v)
|
||||
return v;
|
||||
if (typeof args.scene === 'string') {
|
||||
if (!validateSubPath(v.projectPath, args.scene)) {
|
||||
return createErrorResponse(`Invalid scene path: must be project-relative without ".." (got: ${args.scene})`, ['Pass scene as a path relative to the project root, e.g. "scenes/main.tscn"']);
|
||||
}
|
||||
}
|
||||
if (!runner.getGodotPath()) {
|
||||
await runner.detectGodotPath();
|
||||
if (!runner.getGodotPath()) {
|
||||
return createErrorResponse('Could not find a valid Godot executable path', [
|
||||
'Set GODOT_PATH in your MCP client config to your Godot 4.x executable',
|
||||
'Ensure the path points at the Godot binary, not its installation folder',
|
||||
'On Windows, escape backslashes in JSON (e.g. "D:\\\\Godot\\\\Godot.exe")',
|
||||
]);
|
||||
}
|
||||
}
|
||||
try {
|
||||
const background = args.background === true;
|
||||
const bridgePort = args.bridgePort;
|
||||
if (bridgePort !== undefined) {
|
||||
if (!Number.isInteger(bridgePort) ||
|
||||
bridgePort < 1 ||
|
||||
bridgePort > 65535) {
|
||||
return createErrorResponse(`Invalid bridgePort: must be an integer in [1, 65535] (got: ${String(bridgePort)})`, ['Omit bridgePort to auto-select a free port', 'Pass a valid TCP port number']);
|
||||
}
|
||||
}
|
||||
await runner.runProject(v.projectPath, args.scene, background, bridgePort);
|
||||
const bridgeResult = await runner.waitForBridge();
|
||||
if (!bridgeResult.ready) {
|
||||
if (runner.activeProcess && runner.activeProcess.hasExited) {
|
||||
// Tear down the spawned-mode session state so a retry of run_project
|
||||
// works without an intervening stop_project.
|
||||
await runner.stopProject();
|
||||
return createErrorResponse(`Godot process exited before the MCP bridge could initialize.\n${bridgeResult.error || ''}`, [
|
||||
'Check get_debug_output for runtime errors',
|
||||
'Verify a display server is available (Wayland/X11)',
|
||||
'Check for broken autoloads with list_autoloads',
|
||||
'Retry run_project once the underlying issue is resolved',
|
||||
]);
|
||||
}
|
||||
const recentErrors = runner.getRecentErrors(20);
|
||||
const errorTail = recentErrors.length > 0 ? `\nLast stderr:\n${recentErrors.join('\n')}` : '';
|
||||
const expected = runner.activeBridgePort;
|
||||
const onDisk = runner.readBakedBridgePort(v.projectPath);
|
||||
const raceDetected = onDisk !== null && expected !== null && onDisk !== expected;
|
||||
const racePrefix = raceDetected
|
||||
? `Bridge timeout: expected port ${expected}, but on-disk script now has ${onDisk}. Another MCP client likely re-injected concurrently in the same project.\n`
|
||||
: '';
|
||||
const lines = [
|
||||
`${racePrefix}Godot process started, but the MCP bridge did not respond within ${BRIDGE_WAIT_SPAWNED_TIMEOUT_MS / 1000} seconds.`,
|
||||
'- The bridge listener never came up — likely an early _ready error or a stuck process holding the port',
|
||||
'- Session has been torn down; retry run_project to start a new one',
|
||||
errorTail,
|
||||
];
|
||||
if (background) {
|
||||
lines.push('- Background mode: window hidden, physical input blocked');
|
||||
}
|
||||
// Tear down before returning so hasActiveRuntimeSession() reports false
|
||||
// and the next run_project lazy-reconnects cleanly.
|
||||
await runner.stopProject();
|
||||
const solutions = [
|
||||
'Check for broken autoloads with list_autoloads',
|
||||
`Check that the assigned bridge port (${runner.activeBridgePort}) is not occupied by another Godot process`,
|
||||
'Retry run_project',
|
||||
];
|
||||
if (raceDetected) {
|
||||
solutions.push('Concurrent MCP clients in the same project are not supported — run them in separate projects or sequence the calls');
|
||||
}
|
||||
return createErrorResponse(lines.join('\n'), solutions);
|
||||
}
|
||||
const port = runner.activeBridgePort;
|
||||
const lines = [
|
||||
`Godot project started and MCP bridge is ready (port ${port}).`,
|
||||
'- Runtime tools (take_screenshot, simulate_input, get_ui_elements, run_script) are available now',
|
||||
'- Use get_debug_output to check runtime output and errors',
|
||||
'- Call stop_project when done',
|
||||
];
|
||||
if (background) {
|
||||
lines.push('- Background mode: window hidden, physical input blocked');
|
||||
}
|
||||
return {
|
||||
content: [{ type: 'text', text: lines.join('\n') }],
|
||||
};
|
||||
}
|
||||
catch (error) {
|
||||
const errorMessage = getErrorMessage(error);
|
||||
if (errorMessage.includes('No display server available')) {
|
||||
return createErrorResponse(`Failed to run Godot project: ${errorMessage}`, [
|
||||
'Use attach_project with an externally launched Godot process',
|
||||
'Set DISPLAY or WAYLAND_DISPLAY environment variables',
|
||||
'Run from a graphical shell session',
|
||||
]);
|
||||
}
|
||||
return createErrorResponse(`Failed to run Godot project: ${errorMessage}`, [
|
||||
'Ensure Godot is installed correctly',
|
||||
'Check if the GODOT_PATH environment variable is set correctly',
|
||||
]);
|
||||
}
|
||||
}
|
||||
export async function handleAttachProject(runner, args) {
|
||||
args = normalizeParameters(args);
|
||||
const v = validateProjectArgs(args);
|
||||
if ('isError' in v)
|
||||
return v;
|
||||
try {
|
||||
const attachBridgePort = args.bridgePort;
|
||||
if (attachBridgePort !== undefined) {
|
||||
if (!Number.isInteger(attachBridgePort) ||
|
||||
attachBridgePort < 1 ||
|
||||
attachBridgePort > 65535) {
|
||||
return createErrorResponse(`Invalid bridgePort: must be an integer in [1, 65535] (got: ${String(attachBridgePort)})`, [
|
||||
'Omit bridgePort to auto-select a free port',
|
||||
'Pass a valid TCP port number matching the externally launched Godot',
|
||||
]);
|
||||
}
|
||||
}
|
||||
await runner.attachProject(v.projectPath, attachBridgePort);
|
||||
const bridgeResult = await runner.waitForBridgeAttached();
|
||||
if (!bridgeResult.ready) {
|
||||
const expected = runner.activeBridgePort;
|
||||
const onDisk = runner.readBakedBridgePort(v.projectPath);
|
||||
const raceDetected = onDisk !== null && expected !== null && onDisk !== expected;
|
||||
const racePrefix = raceDetected
|
||||
? `Bridge timeout: expected port ${expected}, but on-disk script now has ${onDisk}. Another MCP client likely re-injected concurrently in the same project.\n`
|
||||
: '';
|
||||
// Tear down the attached-mode session state so retrying with
|
||||
// attach_project (or run_project) works without a manual detach first.
|
||||
await runner.stopProject();
|
||||
const solutions = [
|
||||
'If you are launching Godot yourself, run the launch in parallel with attach_project next time so the wait absorbs the startup — do not sequentialize',
|
||||
'If a human is launching Godot, retry attach_project once they have launched — bridge.inject is idempotent',
|
||||
'If Godot is already running but was launched before the bridge was injected, restart it (autoloads are read at startup)',
|
||||
`Check that no other Godot project is occupying the assigned bridge port (${runner.activeBridgePort})`,
|
||||
];
|
||||
if (raceDetected) {
|
||||
solutions.push('Concurrent MCP clients in the same project are not supported — run them in separate projects or sequence the calls');
|
||||
}
|
||||
return createErrorResponse(`${racePrefix}Project attached but the MCP bridge is not ready.\n${bridgeResult.error || ''}`, solutions);
|
||||
}
|
||||
const attachedPort = runner.activeBridgePort;
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: [
|
||||
`Project attached and MCP bridge is ready (port ${attachedPort}).`,
|
||||
'- Runtime tools (take_screenshot, simulate_input, get_ui_elements, run_script) are available now',
|
||||
'- get_debug_output is unavailable in attached mode because MCP did not spawn the process',
|
||||
'- Use detach_project or stop_project when done to clean up the injected bridge state',
|
||||
].join('\n'),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
catch (error) {
|
||||
return createErrorResponse(`Failed to attach project: ${getErrorMessage(error)}`, [
|
||||
'Check if project.godot is accessible',
|
||||
'Ensure MCP can write the bridge autoload into the project',
|
||||
]);
|
||||
}
|
||||
}
|
||||
export async function handleDetachProject(runner) {
|
||||
if (runner.activeSessionMode !== 'attached') {
|
||||
return createErrorResponse('No attached project to detach.', [
|
||||
'Use attach_project first for manual-launch workflows',
|
||||
'If MCP launched the game, use stop_project instead',
|
||||
]);
|
||||
}
|
||||
const result = (await runner.stopProject());
|
||||
return createStructuredResponse({
|
||||
message: 'Detached attached project and cleaned MCP bridge state',
|
||||
externalProcessPreserved: result.externalProcessPreserved === true,
|
||||
});
|
||||
}
|
||||
export function handleGetDebugOutput(runner, args = {}) {
|
||||
args = normalizeParameters(args);
|
||||
if (!runner.activeSessionMode) {
|
||||
return createErrorResponse('No active runtime session.', [
|
||||
'Use run_project to start a Godot project first',
|
||||
'Or use attach_project before launching Godot manually',
|
||||
]);
|
||||
}
|
||||
if (runner.activeSessionMode === 'attached') {
|
||||
return createStructuredResponse({
|
||||
output: [],
|
||||
errors: [],
|
||||
running: null,
|
||||
attached: true,
|
||||
tip: 'Attached mode does not capture stdout/stderr because Godot was launched outside MCP.',
|
||||
});
|
||||
}
|
||||
const proc = runner.activeProcess;
|
||||
if (!proc) {
|
||||
return createErrorResponse('No active spawned process is available for debug output.', [
|
||||
'Use run_project to start a Godot project first',
|
||||
'Or use attach_project only when stdout/stderr capture is not needed',
|
||||
]);
|
||||
}
|
||||
const limit = typeof args.limit === 'number' ? args.limit : 200;
|
||||
const response = {
|
||||
output: proc.output.slice(-limit),
|
||||
errors: proc.errors.slice(-limit),
|
||||
running: !proc.hasExited,
|
||||
};
|
||||
if (proc.hasExited) {
|
||||
response.exitCode = proc.exitCode;
|
||||
response.tip =
|
||||
'Process has exited. Call stop_project to clean up the process slot before starting a new one.';
|
||||
}
|
||||
return createStructuredResponse(response);
|
||||
}
|
||||
export async function handleStopProject(runner) {
|
||||
const result = await runner.stopProject();
|
||||
if (!result) {
|
||||
return createErrorResponse('No active Godot process to stop.', [
|
||||
'Use run_project to start a Godot project first',
|
||||
'The process may have already terminated',
|
||||
]);
|
||||
}
|
||||
return createStructuredResponse({
|
||||
message: result.mode === 'attached'
|
||||
? 'Attached project detached and MCP bridge state cleaned up'
|
||||
: 'Godot project stopped',
|
||||
mode: result.mode,
|
||||
externalProcessPreserved: result.externalProcessPreserved === true,
|
||||
finalOutput: result.output.slice(-200),
|
||||
finalErrors: result.errors.slice(-200),
|
||||
});
|
||||
}
|
||||
function parseScreenshotResponseMode(value) {
|
||||
if (value === undefined)
|
||||
return 'preview';
|
||||
if (typeof value !== 'string')
|
||||
return null;
|
||||
return SCREENSHOT_RESPONSE_MODES.includes(value)
|
||||
? value
|
||||
: null;
|
||||
}
|
||||
function parsePreviewDimension(value, fallback) {
|
||||
if (value === undefined)
|
||||
return fallback;
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0)
|
||||
return null;
|
||||
return Math.max(1, Math.floor(value));
|
||||
}
|
||||
function normalizeScreenshotPath(path) {
|
||||
return sep === '\\' ? path.replace(/\//g, '\\') : path;
|
||||
}
|
||||
export async function handleTakeScreenshot(runner, args) {
|
||||
args = normalizeParameters(args);
|
||||
const sessionError = ensureRuntimeSession(runner, 'take a screenshot');
|
||||
if (sessionError) {
|
||||
return sessionError;
|
||||
}
|
||||
const timeout = typeof args.timeout === 'number' ? args.timeout : 10000;
|
||||
const responseMode = parseScreenshotResponseMode(args.responseMode);
|
||||
if (responseMode === null) {
|
||||
return createErrorResponse('Invalid responseMode for take_screenshot', [
|
||||
'Use one of: "full", "preview", or "path_only"',
|
||||
]);
|
||||
}
|
||||
const previewMaxWidth = parsePreviewDimension(args.previewMaxWidth, DEFAULT_PREVIEW_MAX_WIDTH);
|
||||
const previewMaxHeight = parsePreviewDimension(args.previewMaxHeight, DEFAULT_PREVIEW_MAX_HEIGHT);
|
||||
if (previewMaxWidth === null || previewMaxHeight === null) {
|
||||
return createErrorResponse('Invalid preview dimensions for take_screenshot', [
|
||||
'previewMaxWidth and previewMaxHeight must be positive numbers',
|
||||
]);
|
||||
}
|
||||
const commandParams = {};
|
||||
if (responseMode === 'preview') {
|
||||
commandParams.preview_max_width = previewMaxWidth;
|
||||
commandParams.preview_max_height = previewMaxHeight;
|
||||
}
|
||||
try {
|
||||
const { response: responseStr, runtimeErrors } = await runner.sendCommandWithErrors('screenshot', commandParams, timeout);
|
||||
const parsedResult = parseBridgeJson(responseStr, 'screenshot');
|
||||
if (!parsedResult.ok)
|
||||
return parsedResult.response;
|
||||
const parsed = parsedResult.data;
|
||||
if (parsed.error) {
|
||||
return createErrorResponse(`Screenshot server error: ${parsed.error}`, [
|
||||
'Ensure the project has a viewport (a headless project with no display server cannot render)',
|
||||
'Check disk space and permissions on the project directory (.mcp/screenshots/)',
|
||||
]);
|
||||
}
|
||||
if (!parsed.path) {
|
||||
return createErrorResponse('Screenshot server returned no file path', [
|
||||
'The bridge response is missing the expected `path` field — this is a bridge bug, not a timing issue',
|
||||
'Check get_debug_output for runtime errors during the screenshot save',
|
||||
]);
|
||||
}
|
||||
// Normalize path for the local filesystem (forward slashes from GDScript)
|
||||
const screenshotPath = normalizeScreenshotPath(parsed.path);
|
||||
// Defense-in-depth: the bridge runs in user-controlled GDScript and could
|
||||
// be patched to return any path. Refuse to read anything outside the
|
||||
// project's own .mcp/screenshots/ directory.
|
||||
const screenshotsRoot = resolve(runner.activeProjectPath, '.mcp', 'screenshots');
|
||||
if (!isUnderDir(screenshotsRoot, screenshotPath)) {
|
||||
return createErrorResponse('Bridge returned a screenshot path outside .mcp/screenshots/. Refusing to read.', [
|
||||
'This indicates a tampered or misbehaving McpBridge autoload',
|
||||
'Stop the project, verify the bridge script is the one shipped with this server, and retry',
|
||||
]);
|
||||
}
|
||||
if (!existsSync(screenshotPath)) {
|
||||
return createErrorResponse(`Screenshot file not found at: ${screenshotPath}`, [
|
||||
'The screenshot may have failed to save',
|
||||
'Check disk space and permissions',
|
||||
]);
|
||||
}
|
||||
const metadata = {
|
||||
responseMode,
|
||||
path: parsed.path,
|
||||
size: { width: parsed.width, height: parsed.height },
|
||||
};
|
||||
const content = [];
|
||||
if (responseMode === 'full') {
|
||||
const imageBuffer = readFileSync(screenshotPath);
|
||||
content.push({
|
||||
type: 'image',
|
||||
data: imageBuffer.toString('base64'),
|
||||
mimeType: 'image/png',
|
||||
});
|
||||
}
|
||||
else if (responseMode === 'preview') {
|
||||
if (!parsed.preview_path) {
|
||||
return createErrorResponse('Screenshot server returned no preview path', [
|
||||
'Ensure the running project has the current McpBridge autoload',
|
||||
'Restart the runtime after rebuilding the MCP server',
|
||||
]);
|
||||
}
|
||||
const previewPath = normalizeScreenshotPath(parsed.preview_path);
|
||||
if (!isUnderDir(screenshotsRoot, previewPath)) {
|
||||
return createErrorResponse('Bridge returned a screenshot preview path outside .mcp/screenshots/. Refusing to read.', [
|
||||
'This indicates a tampered or misbehaving McpBridge autoload',
|
||||
'Stop the project, verify the bridge script is the one shipped with this server, and retry',
|
||||
]);
|
||||
}
|
||||
if (!existsSync(previewPath)) {
|
||||
return createErrorResponse(`Screenshot preview file not found at: ${previewPath}`, [
|
||||
'The preview may have failed to save',
|
||||
'Try again, or use responseMode "full" to return the original screenshot',
|
||||
]);
|
||||
}
|
||||
const previewBuffer = readFileSync(previewPath);
|
||||
content.push({
|
||||
type: 'image',
|
||||
data: previewBuffer.toString('base64'),
|
||||
mimeType: 'image/png',
|
||||
});
|
||||
metadata.previewPath = parsed.preview_path;
|
||||
metadata.previewSize = { width: parsed.preview_width, height: parsed.preview_height };
|
||||
}
|
||||
attachRuntimeWarnings(metadata, runtimeErrors);
|
||||
return createStructuredResponse(metadata, content);
|
||||
}
|
||||
catch (error) {
|
||||
return createErrorResponse(`Failed to take screenshot: ${getErrorMessage(error)}`, [
|
||||
'Check get_debug_output for crash backtraces or runtime errors',
|
||||
'If the game has exited, call stop_project, then run_project again',
|
||||
'For slow renders, increase the timeout parameter',
|
||||
]);
|
||||
}
|
||||
}
|
||||
export async function handleSimulateInput(runner, args) {
|
||||
args = normalizeParameters(args);
|
||||
const sessionError = ensureRuntimeSession(runner, 'simulate input');
|
||||
if (sessionError) {
|
||||
return sessionError;
|
||||
}
|
||||
const actions = args.actions;
|
||||
if (!Array.isArray(actions) || actions.length === 0) {
|
||||
return createErrorResponse('actions must be a non-empty array of input actions', [
|
||||
'Provide at least one action object with a "type" field',
|
||||
]);
|
||||
}
|
||||
// Calculate timeout: sum of all wait durations + 10s buffer
|
||||
let totalWaitMs = 0;
|
||||
for (const action of actions) {
|
||||
if (typeof action === 'object' &&
|
||||
action !== null &&
|
||||
action.type === 'wait' &&
|
||||
typeof action.ms === 'number') {
|
||||
totalWaitMs += action.ms;
|
||||
}
|
||||
}
|
||||
const timeoutMs = totalWaitMs + 10000;
|
||||
try {
|
||||
const { response: responseStr, runtimeErrors } = await runner.sendCommandWithErrors('input', { actions }, timeoutMs);
|
||||
const parsedResult = parseBridgeJson(responseStr, 'simulate_input');
|
||||
if (!parsedResult.ok)
|
||||
return parsedResult.response;
|
||||
const parsed = parsedResult.data;
|
||||
if (parsed.error) {
|
||||
return createErrorResponse(`Input simulation error: ${parsed.error}`, [
|
||||
'Check action types and parameters',
|
||||
'Ensure key names are valid Godot key names',
|
||||
]);
|
||||
}
|
||||
const payload = {
|
||||
success: true,
|
||||
actions_processed: parsed.actions_processed,
|
||||
tip: 'Call take_screenshot to verify the input had the intended visual effect.',
|
||||
};
|
||||
attachRuntimeWarnings(payload, runtimeErrors);
|
||||
return createStructuredResponse(payload);
|
||||
}
|
||||
catch (error) {
|
||||
return createErrorResponse(`Failed to simulate input: ${getErrorMessage(error)}`, [
|
||||
'Check get_debug_output for crash backtraces or runtime errors (a signal handler firing on input may have crashed the game)',
|
||||
'If the game has exited, call stop_project, then run_project again',
|
||||
]);
|
||||
}
|
||||
}
|
||||
export async function handleGetUiElements(runner, args) {
|
||||
args = normalizeParameters(args);
|
||||
const sessionError = ensureRuntimeSession(runner, 'query UI elements');
|
||||
if (sessionError) {
|
||||
return sessionError;
|
||||
}
|
||||
const visibleOnly = args.visibleOnly !== false;
|
||||
try {
|
||||
const cmdParams = { visible_only: visibleOnly };
|
||||
if (args.filter)
|
||||
cmdParams.type_filter = args.filter;
|
||||
const { response: responseStr, runtimeErrors } = await runner.sendCommandWithErrors('get_ui_elements', cmdParams);
|
||||
const parsedResult = parseBridgeJson(responseStr, 'get_ui_elements');
|
||||
if (!parsedResult.ok)
|
||||
return parsedResult.response;
|
||||
const parsed = parsedResult.data;
|
||||
if (parsed.error) {
|
||||
return createErrorResponse(`UI element query error: ${parsed.error}`, [
|
||||
'Ensure the game has a UI with Control nodes',
|
||||
]);
|
||||
}
|
||||
const payload = {
|
||||
...parsed,
|
||||
tip: "Use simulate_input with type 'click_element' and a node_path or node name from this list to interact with these elements.",
|
||||
};
|
||||
attachRuntimeWarnings(payload, runtimeErrors);
|
||||
return createStructuredResponse(payload);
|
||||
}
|
||||
catch (error) {
|
||||
return createErrorResponse(`Failed to get UI elements: ${getErrorMessage(error)}`, [
|
||||
'Check get_debug_output for crash backtraces or runtime errors',
|
||||
'If the game has exited, call stop_project, then run_project again',
|
||||
]);
|
||||
}
|
||||
}
|
||||
export async function handleRunScript(runner, args) {
|
||||
args = normalizeParameters(args);
|
||||
const sessionError = ensureRuntimeSession(runner, 'execute scripts');
|
||||
if (sessionError) {
|
||||
return sessionError;
|
||||
}
|
||||
const script = args.script;
|
||||
if (typeof script !== 'string' || script.trim() === '') {
|
||||
return createErrorResponse('script is required and must be a non-empty string', [
|
||||
'Provide GDScript source code with extends RefCounted and func execute(scene_tree: SceneTree) -> Variant',
|
||||
]);
|
||||
}
|
||||
if (!script.includes('func execute')) {
|
||||
return createErrorResponse('Script must define func execute(scene_tree: SceneTree) -> Variant', ['Add a func execute(scene_tree: SceneTree) -> Variant method to your script']);
|
||||
}
|
||||
// Write script to .mcp/scripts/ for audit trail
|
||||
try {
|
||||
const projectPath = runner.activeProjectPath;
|
||||
if (projectPath) {
|
||||
const scriptsDir = join(projectPath, '.mcp', 'scripts');
|
||||
mkdirSync(scriptsDir, { recursive: true });
|
||||
const timestamp = Date.now();
|
||||
const scriptFile = join(scriptsDir, `${timestamp}-${randomUUID()}.gd`);
|
||||
writeFileSync(scriptFile, script, 'utf8');
|
||||
logDebug(`Saved script to ${scriptFile}`);
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
logDebug(`Failed to save script for audit: ${error}`);
|
||||
}
|
||||
const timeout = typeof args.timeout === 'number' ? args.timeout : 30000;
|
||||
try {
|
||||
const { response: responseStr, runtimeErrors } = await runner.sendCommandWithErrors('run_script', { source: script }, timeout);
|
||||
const parsedResult = parseBridgeJson(responseStr, 'run_script');
|
||||
if (!parsedResult.ok)
|
||||
return parsedResult.response;
|
||||
const parsed = parsedResult.data;
|
||||
if (parsed.error) {
|
||||
return createErrorResponse(`Script execution error: ${parsed.error}`, [
|
||||
'Check your GDScript syntax',
|
||||
'Ensure the script extends RefCounted',
|
||||
'Check get_debug_output for details',
|
||||
]);
|
||||
}
|
||||
// Detect false-positive success: GDScript has no try-catch, so runtime errors
|
||||
// return null and the real error only appears in stderr.
|
||||
if (parsed.success && parsed.result === null && runner.activeSessionMode === 'spawned') {
|
||||
if (runtimeErrors.length > 0) {
|
||||
const errorContext = runtimeErrors.slice(0, MAX_RUNTIME_ERROR_CONTEXT_LINES).join('\n');
|
||||
return createErrorResponse(`Script runtime error detected:\n${errorContext}`, [
|
||||
'Fix the GDScript error in your script and retry',
|
||||
'Use get_debug_output for full process output',
|
||||
]);
|
||||
}
|
||||
return createStructuredResponse({
|
||||
success: true,
|
||||
result: null,
|
||||
warnings: [
|
||||
'Script returned null. If unexpected, check get_debug_output for runtime errors — GDScript does not propagate exceptions.',
|
||||
],
|
||||
tip: 'Call take_screenshot to verify any visual changes, or get_debug_output to review print() output from your script.',
|
||||
});
|
||||
}
|
||||
const payload = {
|
||||
success: true,
|
||||
result: parsed.result,
|
||||
tip: 'Call take_screenshot to verify any visual changes, or get_debug_output to review print() output from your script.',
|
||||
};
|
||||
attachRuntimeWarnings(payload, runtimeErrors);
|
||||
return createStructuredResponse(payload);
|
||||
}
|
||||
catch (error) {
|
||||
return createErrorResponse(`Failed to execute script: ${getErrorMessage(error)}`, [
|
||||
'Check get_debug_output for crash backtraces or runtime errors raised inside the script',
|
||||
'If the game has exited, call stop_project, then run_project again',
|
||||
'For long-running scripts, increase the timeout parameter',
|
||||
]);
|
||||
}
|
||||
}
|
||||
//# sourceMappingURL=runtime-tools.js.map
|
||||
+1
File diff suppressed because one or more lines are too long
Vendored
+45
@@ -0,0 +1,45 @@
|
||||
import type { GodotRunner, OperationParams, ToolDefinition } from '../utils/godot-runner.js';
|
||||
export declare const sceneToolDefinitions: ToolDefinition[];
|
||||
export declare function handleCreateScene(runner: GodotRunner, args: OperationParams): Promise<import("../utils/godot-runner.js").ToolResponse | {
|
||||
content: Array<{
|
||||
type: "text";
|
||||
text: string;
|
||||
}>;
|
||||
isError: boolean;
|
||||
}>;
|
||||
export declare function handleAddNode(runner: GodotRunner, args: OperationParams): Promise<import("../utils/godot-runner.js").ToolResponse | {
|
||||
content: Array<{
|
||||
type: "text";
|
||||
text: string;
|
||||
}>;
|
||||
isError: boolean;
|
||||
}>;
|
||||
export declare function handleLoadSprite(runner: GodotRunner, args: OperationParams): Promise<import("../utils/godot-runner.js").ToolResponse | {
|
||||
content: Array<{
|
||||
type: "text";
|
||||
text: string;
|
||||
}>;
|
||||
isError: boolean;
|
||||
}>;
|
||||
export declare function handleSaveScene(runner: GodotRunner, args: OperationParams): Promise<import("../utils/godot-runner.js").ToolResponse | {
|
||||
content: Array<{
|
||||
type: "text";
|
||||
text: string;
|
||||
}>;
|
||||
isError: boolean;
|
||||
}>;
|
||||
export declare function handleExportMeshLibrary(runner: GodotRunner, args: OperationParams): Promise<import("../utils/godot-runner.js").ToolResponse | {
|
||||
content: Array<{
|
||||
type: "text";
|
||||
text: string;
|
||||
}>;
|
||||
isError: boolean;
|
||||
}>;
|
||||
export declare function handleBatchSceneOperations(runner: GodotRunner, args: OperationParams): Promise<import("../utils/godot-runner.js").ToolResponse | {
|
||||
content: Array<{
|
||||
type: "text";
|
||||
text: string;
|
||||
}>;
|
||||
isError: boolean;
|
||||
}>;
|
||||
//# sourceMappingURL=scene-tools.d.ts.map
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"scene-tools.d.ts","sourceRoot":"","sources":["../../src/tools/scene-tools.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,WAAW,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAW7F,eAAO,MAAM,oBAAoB,EAAE,cAAc,EAyNhD,CAAC;AAIF,wBAAsB,iBAAiB,CAAC,MAAM,EAAE,WAAW,EAAE,IAAI,EAAE,eAAe;;;;;;GAmBjF;AAED,wBAAsB,aAAa,CAAC,MAAM,EAAE,WAAW,EAAE,IAAI,EAAE,eAAe;;;;;;GAsC7E;AAED,wBAAsB,gBAAgB,CAAC,MAAM,EAAE,WAAW,EAAE,IAAI,EAAE,eAAe;;;;;;GA4BhF;AAED,wBAAsB,eAAe,CAAC,MAAM,EAAE,WAAW,EAAE,IAAI,EAAE,eAAe;;;;;;GAgB/E;AAED,wBAAsB,uBAAuB,CAAC,MAAM,EAAE,WAAW,EAAE,IAAI,EAAE,eAAe;;;;;;GA0BvF;AAED,wBAAsB,0BAA0B,CAAC,MAAM,EAAE,WAAW,EAAE,IAAI,EAAE,eAAe;;;;;;GAyB1F"}
|
||||
Vendored
+342
@@ -0,0 +1,342 @@
|
||||
import { join } from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
import { normalizeParameters, validateSubPath, validateNodePath, createErrorResponse, validateProjectArgs, validateSceneArgs, } from '../utils/godot-runner.js';
|
||||
import { executeSceneOp } from '../utils/handler-helpers.js';
|
||||
export const sceneToolDefinitions = [
|
||||
{
|
||||
name: 'create_scene',
|
||||
description: 'Create a new Godot scene file with a single root node. Writes a fresh .tscn at scenePath. Use when starting a new scene from scratch; for adding nodes to an existing scene, use add_node. rootNodeType defaults to Node2D — pass "Node3D" for 3D scenes or "Control" for UI. Saves automatically. Overwrites silently if the file already exists. Returns: success and the scenePath that was written.',
|
||||
annotations: { idempotentHint: true },
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
projectPath: { type: 'string', description: 'Path to the Godot project directory' },
|
||||
scenePath: {
|
||||
type: 'string',
|
||||
description: 'Scene file path relative to the project (e.g. "scenes/main.tscn")',
|
||||
},
|
||||
rootNodeType: { type: 'string', description: 'Root node type (default: Node2D)' },
|
||||
},
|
||||
required: ['projectPath', 'scenePath'],
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean' },
|
||||
scenePath: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'add_node',
|
||||
description: 'Add a node to a Godot scene. Saves automatically. Common spatial properties (position, position3d, rotation, scale, visible, modulate) can be set as top-level params; for any other property, pass it under properties. Vector2/Vector3/Color values auto-convert from {x,y}/{x,y,z}/{r,g,b,a}. parentNodePath defaults to the scene root. Returns a plain-text confirmation message naming the new node and type. Errors if nodeType is not a registered Godot class or parentNodePath does not exist.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
projectPath: { type: 'string', description: 'Path to the Godot project directory' },
|
||||
scenePath: { type: 'string', description: 'Scene file path relative to the project' },
|
||||
nodeType: {
|
||||
type: 'string',
|
||||
description: 'Godot node class to instantiate (e.g. "Sprite2D", "CollisionShape2D", "Label")',
|
||||
},
|
||||
nodeName: {
|
||||
type: 'string',
|
||||
description: 'Name for the new node as it appears in the scene tree',
|
||||
},
|
||||
parentNodePath: {
|
||||
type: 'string',
|
||||
description: 'Parent node path from scene root (e.g. "root/Player"). Defaults to the root node.',
|
||||
},
|
||||
position: {
|
||||
type: 'object',
|
||||
description: 'Vector2 position (e.g. {"x": 100, "y": 200})',
|
||||
properties: { x: { type: 'number' }, y: { type: 'number' } },
|
||||
},
|
||||
position3d: {
|
||||
type: 'object',
|
||||
description: 'Vector3 position for 3D nodes (e.g. {"x": 0, "y": 1, "z": 0})',
|
||||
properties: { x: { type: 'number' }, y: { type: 'number' }, z: { type: 'number' } },
|
||||
},
|
||||
rotation: { type: 'number', description: 'Rotation in radians' },
|
||||
scale: {
|
||||
type: 'object',
|
||||
description: 'Vector2 scale (e.g. {"x": 2, "y": 2})',
|
||||
properties: { x: { type: 'number' }, y: { type: 'number' } },
|
||||
},
|
||||
visible: { type: 'boolean', description: 'Whether the node is visible' },
|
||||
modulate: {
|
||||
type: 'object',
|
||||
description: 'Color modulation (e.g. {"r": 1, "g": 0, "b": 0, "a": 1})',
|
||||
properties: {
|
||||
r: { type: 'number' },
|
||||
g: { type: 'number' },
|
||||
b: { type: 'number' },
|
||||
a: { type: 'number' },
|
||||
},
|
||||
},
|
||||
properties: {
|
||||
type: 'object',
|
||||
description: 'Additional property values as a JSON object. Top-level params (position, rotation, etc.) take precedence over keys in this dict.',
|
||||
},
|
||||
},
|
||||
required: ['projectPath', 'scenePath', 'nodeType', 'nodeName'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'load_sprite',
|
||||
description: 'Set the texture on an existing Sprite2D, Sprite3D, or TextureRect node. Use this when the node already exists; for new nodes, pass texture via add_node properties. Saves automatically. texturePath must be a real file under projectPath. Returns a plain-text confirmation message naming the loaded texture. Errors if the node is not one of those three classes, or the texture file does not exist.',
|
||||
annotations: { idempotentHint: true },
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
projectPath: { type: 'string', description: 'Path to the Godot project directory' },
|
||||
scenePath: { type: 'string', description: 'Scene file path relative to the project' },
|
||||
nodePath: {
|
||||
type: 'string',
|
||||
description: 'Path to the target node from scene root (e.g. "root/Player/Sprite2D")',
|
||||
},
|
||||
texturePath: {
|
||||
type: 'string',
|
||||
description: 'Path to the texture file relative to the project (e.g. "assets/player.png")',
|
||||
},
|
||||
},
|
||||
required: ['projectPath', 'scenePath', 'nodePath', 'texturePath'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'save_scene',
|
||||
description: 'Re-pack and save a scene, optionally to a different path (save-as). Most mutations (add_node, set_node_properties, delete_nodes, etc.) auto-save — only use this for save-as via newPath, or to re-canonicalize a hand-edited .tscn. Overwrites silently. Returns a plain-text confirmation naming the save path. Errors if the scene file does not exist.',
|
||||
annotations: { idempotentHint: true },
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
projectPath: { type: 'string', description: 'Path to the Godot project directory' },
|
||||
scenePath: { type: 'string', description: 'Scene file path relative to the project' },
|
||||
newPath: {
|
||||
type: 'string',
|
||||
description: 'Save to a different path (relative to project) instead of overwriting the original',
|
||||
},
|
||||
},
|
||||
required: ['projectPath', 'scenePath'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'export_mesh_library',
|
||||
description: 'Export a scene of MeshInstance3D nodes as a MeshLibrary .res file for use in GridMap. Use this when authoring tile palettes for grid-based 3D levels; ignore for 2D or general scene work. The source scene must contain MeshInstance3D children. Pass meshItemNames to export a subset, or omit to export all. Saves the .res to outputPath, overwriting silently. Returns a plain-text confirmation with the exported item count. Errors if the scene contains no valid meshes.',
|
||||
annotations: { destructiveHint: true },
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
projectPath: { type: 'string', description: 'Path to the Godot project directory' },
|
||||
scenePath: { type: 'string', description: 'Scene file path relative to the project' },
|
||||
outputPath: {
|
||||
type: 'string',
|
||||
description: 'Output path for the MeshLibrary .res file (relative to project)',
|
||||
},
|
||||
meshItemNames: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'Names of specific mesh items to export. Omit to export all.',
|
||||
},
|
||||
},
|
||||
required: ['projectPath', 'scenePath', 'outputPath'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'batch_scene_operations',
|
||||
description: 'Use this instead of chaining add_node / load_sprite / save_scene calls when you have multiple mutations on the same or related scenes — runs in one Godot process (~3s startup avoided per call) and shares an in-memory scene cache, saving once at the end. Each item picks its sub-operation (add_node, load_sprite, save) and supplies its own params; abortOnError stops on first failure (default false continues). Returns: results[] in input order, each tagged with operation and scenePath plus success or error.',
|
||||
annotations: { destructiveHint: true },
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
projectPath: { type: 'string', description: 'Path to the Godot project directory' },
|
||||
operations: {
|
||||
type: 'array',
|
||||
description: 'Ordered list of scene operations. Each item has its own operation and scenePath.',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
operation: {
|
||||
type: 'string',
|
||||
enum: ['add_node', 'load_sprite', 'save'],
|
||||
description: 'The sub-operation to perform',
|
||||
},
|
||||
scenePath: { type: 'string', description: 'Scene file path for this operation' },
|
||||
nodeType: { type: 'string', description: '[add_node] Node class to instantiate' },
|
||||
nodeName: { type: 'string', description: '[add_node] Name for the new node' },
|
||||
parentNodePath: {
|
||||
type: 'string',
|
||||
description: '[add_node] Parent node path (defaults to root)',
|
||||
},
|
||||
properties: { type: 'object', description: '[add_node] Initial property values' },
|
||||
nodePath: { type: 'string', description: '[load_sprite] Target node path' },
|
||||
texturePath: {
|
||||
type: 'string',
|
||||
description: '[load_sprite] Texture file path relative to project',
|
||||
},
|
||||
newPath: {
|
||||
type: 'string',
|
||||
description: '[save] Save to a different path instead of overwriting',
|
||||
},
|
||||
},
|
||||
required: ['operation'],
|
||||
},
|
||||
},
|
||||
abortOnError: {
|
||||
type: 'boolean',
|
||||
description: 'Stop processing on first error (default: false)',
|
||||
},
|
||||
},
|
||||
required: ['projectPath', 'operations'],
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
results: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
operation: { type: 'string' },
|
||||
scenePath: { type: 'string' },
|
||||
success: { type: 'boolean' },
|
||||
error: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
// --- Handlers ---
|
||||
export async function handleCreateScene(runner, args) {
|
||||
args = normalizeParameters(args);
|
||||
const v = validateSceneArgs(args, { sceneRequired: false });
|
||||
if ('isError' in v)
|
||||
return v;
|
||||
const params = {
|
||||
scenePath: args.scenePath,
|
||||
rootNodeType: args.rootNodeType || 'Node2D',
|
||||
};
|
||||
return executeSceneOp(runner, 'create_scene', params, v.projectPath, 'Failed to create scene', ['Check if the root node type is valid'], undefined, { parseStdoutAsJson: true });
|
||||
}
|
||||
export async function handleAddNode(runner, args) {
|
||||
args = normalizeParameters(args);
|
||||
const v = validateSceneArgs(args);
|
||||
if ('isError' in v)
|
||||
return v;
|
||||
if (!args.nodeType || !args.nodeName) {
|
||||
return createErrorResponse('nodeType and nodeName are required', [
|
||||
'Provide both nodeType and nodeName',
|
||||
]);
|
||||
}
|
||||
// Merge promoted top-level params into properties dict
|
||||
const promotedKeys = [
|
||||
'position',
|
||||
'position3d',
|
||||
'rotation',
|
||||
'scale',
|
||||
'visible',
|
||||
'modulate',
|
||||
];
|
||||
const mergedProps = args.properties || {};
|
||||
for (const key of promotedKeys) {
|
||||
if (args[key] !== undefined) {
|
||||
mergedProps[key] = args[key];
|
||||
}
|
||||
}
|
||||
const params = {
|
||||
scenePath: args.scenePath,
|
||||
nodeType: args.nodeType,
|
||||
nodeName: args.nodeName,
|
||||
};
|
||||
if (args.parentNodePath)
|
||||
params.parentNodePath = args.parentNodePath;
|
||||
if (Object.keys(mergedProps).length > 0)
|
||||
params.properties = mergedProps;
|
||||
return executeSceneOp(runner, 'add_node', params, v.projectPath, 'Failed to add node', [
|
||||
'Check if the node type is valid',
|
||||
'Ensure the parent node path exists',
|
||||
]);
|
||||
}
|
||||
export async function handleLoadSprite(runner, args) {
|
||||
args = normalizeParameters(args);
|
||||
const v = validateSceneArgs(args);
|
||||
if ('isError' in v)
|
||||
return v;
|
||||
if (!args.nodePath || !validateNodePath(args.nodePath)) {
|
||||
return createErrorResponse('Valid nodePath is required', ['Provide the target node path']);
|
||||
}
|
||||
if (!args.texturePath || !validateSubPath(v.projectPath, args.texturePath)) {
|
||||
return createErrorResponse('Valid texturePath is required', [
|
||||
'Provide a relative texture path that stays inside the project directory',
|
||||
]);
|
||||
}
|
||||
const textureFullPath = join(v.projectPath, args.texturePath);
|
||||
if (!existsSync(textureFullPath)) {
|
||||
return createErrorResponse(`Texture file does not exist: ${args.texturePath}`, [
|
||||
'Ensure the texture path is correct',
|
||||
]);
|
||||
}
|
||||
const params = {
|
||||
scenePath: args.scenePath,
|
||||
nodePath: args.nodePath,
|
||||
texturePath: args.texturePath,
|
||||
};
|
||||
return executeSceneOp(runner, 'load_sprite', params, v.projectPath, 'Failed to load sprite', [
|
||||
'Check if the node is a Sprite2D, Sprite3D, or TextureRect',
|
||||
]);
|
||||
}
|
||||
export async function handleSaveScene(runner, args) {
|
||||
args = normalizeParameters(args);
|
||||
const v = validateSceneArgs(args);
|
||||
if ('isError' in v)
|
||||
return v;
|
||||
if (args.newPath && !validateSubPath(v.projectPath, args.newPath)) {
|
||||
return createErrorResponse('Invalid newPath', [
|
||||
'Provide a valid relative path without ".." that stays inside the project directory',
|
||||
]);
|
||||
}
|
||||
const params = { scenePath: args.scenePath };
|
||||
if (args.newPath)
|
||||
params.newPath = args.newPath;
|
||||
return executeSceneOp(runner, 'save_scene', params, v.projectPath, 'Failed to save scene', [
|
||||
'Check if the scene file is valid',
|
||||
]);
|
||||
}
|
||||
export async function handleExportMeshLibrary(runner, args) {
|
||||
args = normalizeParameters(args);
|
||||
const v = validateSceneArgs(args);
|
||||
if ('isError' in v)
|
||||
return v;
|
||||
if (!args.outputPath || !validateSubPath(v.projectPath, args.outputPath)) {
|
||||
return createErrorResponse('Valid outputPath is required', [
|
||||
'Provide an output path for the .res file that stays inside the project directory',
|
||||
]);
|
||||
}
|
||||
const params = {
|
||||
scenePath: args.scenePath,
|
||||
outputPath: args.outputPath,
|
||||
};
|
||||
if (args.meshItemNames && Array.isArray(args.meshItemNames)) {
|
||||
params.meshItemNames = args.meshItemNames;
|
||||
}
|
||||
return executeSceneOp(runner, 'export_mesh_library', params, v.projectPath, 'Failed to export mesh library', ['Check if the scene contains valid 3D meshes']);
|
||||
}
|
||||
export async function handleBatchSceneOperations(runner, args) {
|
||||
args = normalizeParameters(args);
|
||||
const v = validateProjectArgs(args);
|
||||
if ('isError' in v)
|
||||
return v;
|
||||
if (!args.operations || !Array.isArray(args.operations)) {
|
||||
return createErrorResponse('operations array is required', [
|
||||
'Provide an operations array with at least one item',
|
||||
]);
|
||||
}
|
||||
const params = {
|
||||
operations: args.operations,
|
||||
abortOnError: args.abortOnError ?? false,
|
||||
};
|
||||
return executeSceneOp(runner, 'batch_scene_operations', params, v.projectPath, 'Batch scene operations failed', ['Check that all scene paths exist', 'Ensure node types are valid'], undefined, { parseStdoutAsJson: true });
|
||||
}
|
||||
//# sourceMappingURL=scene-tools.js.map
|
||||
+1
File diff suppressed because one or more lines are too long
+15
@@ -0,0 +1,15 @@
|
||||
import type { GodotRunner, OperationParams, ToolDefinition } from '../utils/godot-runner.js';
|
||||
export declare const validateToolDefinitions: ToolDefinition[];
|
||||
export declare function handleValidate(runner: GodotRunner, args: OperationParams): Promise<{
|
||||
content: Array<{
|
||||
type: "text";
|
||||
text: string;
|
||||
}>;
|
||||
isError: boolean;
|
||||
} | {
|
||||
content: {
|
||||
type: string;
|
||||
text: string;
|
||||
}[];
|
||||
}>;
|
||||
//# sourceMappingURL=validate-tools.d.ts.map
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"validate-tools.d.ts","sourceRoot":"","sources":["../../src/tools/validate-tools.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,WAAW,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAU7F,eAAO,MAAM,uBAAuB,EAAE,cAAc,EAmDnD,CAAC;AAmIF,wBAAsB,cAAc,CAAC,MAAM,EAAE,WAAW,EAAE,IAAI,EAAE,eAAe;;;;;;;;;;;GAqP9E"}
|
||||
Vendored
+375
@@ -0,0 +1,375 @@
|
||||
import { join } from 'path';
|
||||
import { existsSync, writeFileSync, unlinkSync, mkdirSync } from 'fs';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { normalizeParameters, validateSubPath, validateProjectArgs, createErrorResponse, extractGdError, getErrorMessage, } from '../utils/godot-runner.js';
|
||||
export const validateToolDefinitions = [
|
||||
{
|
||||
name: 'validate',
|
||||
description: "Validate GDScript syntax or scene file integrity using headless Godot. Use before attach_script or run_script to catch parse errors early. Single-target: provide exactly one of scriptPath, source, or scenePath. Batch: provide a targets array — runs all in one Godot process. Returns { valid, errors: [{ line?, message }] } for single, or { results: [{ target, valid, errors }] } for batch. Line numbers appear when Godot's stderr includes them (not always). Returns valid:false on any parse error; never throws.",
|
||||
annotations: { readOnlyHint: true },
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
projectPath: {
|
||||
type: 'string',
|
||||
description: 'Path to the Godot project directory',
|
||||
},
|
||||
scriptPath: {
|
||||
type: 'string',
|
||||
description: '[single] Path to a .gd file relative to the project to validate (e.g. "scripts/player.gd")',
|
||||
},
|
||||
source: {
|
||||
type: 'string',
|
||||
description: '[single] Inline GDScript source code to validate. Written to a temporary file and validated against the project.',
|
||||
},
|
||||
scenePath: {
|
||||
type: 'string',
|
||||
description: '[single] Path to a .tscn scene file relative to the project to validate (e.g. "scenes/main.tscn")',
|
||||
},
|
||||
targets: {
|
||||
type: 'array',
|
||||
description: '[batch] Array of targets to validate in a single Godot process. Each item must have exactly one of: scriptPath, source, or scenePath.',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
scriptPath: {
|
||||
type: 'string',
|
||||
description: 'Path to a .gd file relative to the project',
|
||||
},
|
||||
source: { type: 'string', description: 'Inline GDScript source code' },
|
||||
scenePath: {
|
||||
type: 'string',
|
||||
description: 'Path to a .tscn file relative to the project',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['projectPath'],
|
||||
},
|
||||
},
|
||||
];
|
||||
/**
|
||||
* Core Godot stderr parser. Returns a flat list of error entries, each with an
|
||||
* optional line number and optional res:// file path (from the "at:" line).
|
||||
*/
|
||||
function parseGodotErrorEntries(stderr) {
|
||||
const entries = [];
|
||||
if (!stderr)
|
||||
return entries;
|
||||
const lines = stderr.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
// Pattern: "SCRIPT ERROR: Parse Error: MESSAGE" or "ERROR: MESSAGE"
|
||||
// followed by " at: res://...:LINE" or " at: ...:LINE"
|
||||
const scriptErrorMatch = line.match(/SCRIPT ERROR:\s*(?:Parse Error:\s*)?(.+)/);
|
||||
const errorMatch = !scriptErrorMatch ? line.match(/^ERROR:\s*(.+)/) : null;
|
||||
const match = scriptErrorMatch || errorMatch;
|
||||
if (match) {
|
||||
const message = match[1].trim();
|
||||
let lineNum;
|
||||
let filePath;
|
||||
if (i + 1 < lines.length) {
|
||||
// Try res:// path first (captures file + line). Tolerates an optional
|
||||
// "<method> (" prefix before res:// so we catch both
|
||||
// " at: res://foo.gd:3"
|
||||
// and
|
||||
// " at: GDScript::reload (res://foo.gd:3)"
|
||||
// Real Godot 4.5 stderr uses the parenthesized form.
|
||||
const resAtMatch = lines[i + 1].match(/\s*at:\s*(?:[^()\n]*\()?(res:\/\/[^):"\s]+):(\d+)/);
|
||||
if (resAtMatch) {
|
||||
filePath = resAtMatch[1];
|
||||
lineNum = parseInt(resAtMatch[2], 10);
|
||||
i++;
|
||||
}
|
||||
else {
|
||||
// Fall back to loose match (line only, e.g. native code "at:" lines)
|
||||
const looseAtMatch = lines[i + 1].match(/\s*at:\s*.+:(\d+)/);
|
||||
if (looseAtMatch) {
|
||||
lineNum = parseInt(looseAtMatch[1], 10);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Parse-error entries reference synthetic gdscript:// URIs in their `at:` line
|
||||
// rather than a res:// path. Peek forward up to 10 lines for the secondary
|
||||
// "Failed to load script/resource: \"res://...\"" message that names the file,
|
||||
// and adopt that path so batch error attribution can find it. The window is
|
||||
// intentionally wide enough to clear a full GDScript backtrace.
|
||||
if (!filePath && /Parse Error/i.test(line)) {
|
||||
const lookaheadLimit = Math.min(i + 11, lines.length);
|
||||
for (let j = i + 1; j < lookaheadLimit; j++) {
|
||||
const failMatch = lines[j].match(/Failed to load (?:script|resource):?\s*"?(res:\/\/[^":\s]+)/);
|
||||
if (failMatch) {
|
||||
filePath = failMatch[1];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
entries.push({ message, line: lineNum, filePath });
|
||||
continue;
|
||||
}
|
||||
// Pattern: "Parse Error: MESSAGE at line LINE"
|
||||
const parseErrorMatch = line.match(/Parse Error:\s*(.+?)\s+at line\s+(\d+)/);
|
||||
if (parseErrorMatch) {
|
||||
entries.push({
|
||||
line: parseInt(parseErrorMatch[2], 10),
|
||||
message: parseErrorMatch[1].trim(),
|
||||
});
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
function parseGodotErrors(stderr) {
|
||||
return parseGodotErrorEntries(stderr).map(({ message, line }) => ({ message, line }));
|
||||
}
|
||||
/**
|
||||
* Write inline GDScript source to a uniquely-named file under <projectPath>/.mcp/
|
||||
* for validation. Returns the project-relative path (e.g. ".mcp/validate_temp_xxx.gd")
|
||||
* that the runner consumes plus the absolute path the caller cleans up.
|
||||
*/
|
||||
function writeTempGdScript(projectPath, source, prefix) {
|
||||
const mcpDir = join(projectPath, '.mcp');
|
||||
mkdirSync(mcpDir, { recursive: true });
|
||||
const name = `${prefix}_${randomUUID()}.gd`;
|
||||
const absPath = join(mcpDir, name);
|
||||
writeFileSync(absPath, source, 'utf8');
|
||||
return { resPath: `.mcp/${name}`, absPath };
|
||||
}
|
||||
/**
|
||||
* Group Godot stderr errors by their res:// file path.
|
||||
* Used for batch validation where multiple files produce output in one stderr stream.
|
||||
*/
|
||||
function parseGodotErrorsByPath(stderr) {
|
||||
const result = new Map();
|
||||
for (const { message, line, filePath } of parseGodotErrorEntries(stderr)) {
|
||||
if (filePath) {
|
||||
if (!result.has(filePath))
|
||||
result.set(filePath, []);
|
||||
result.get(filePath).push({ line, message });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
export async function handleValidate(runner, args) {
|
||||
args = normalizeParameters(args);
|
||||
const pv = validateProjectArgs(args);
|
||||
if ('isError' in pv)
|
||||
return pv;
|
||||
// Batch mode: targets array
|
||||
if (args.targets && Array.isArray(args.targets)) {
|
||||
const targets = args.targets;
|
||||
const tempFiles = [];
|
||||
try {
|
||||
const snakeTargets = [];
|
||||
const preErrors = new Map();
|
||||
for (let i = 0; i < targets.length; i++) {
|
||||
const t = targets[i];
|
||||
if (t.source) {
|
||||
const { resPath, absPath } = writeTempGdScript(pv.projectPath, t.source, 'validate_batch');
|
||||
tempFiles.push(absPath);
|
||||
snakeTargets.push({ script_path: resPath });
|
||||
}
|
||||
else if (t.scriptPath) {
|
||||
if (!validateSubPath(pv.projectPath, t.scriptPath)) {
|
||||
preErrors.set(i, {
|
||||
target: t.scriptPath,
|
||||
errors: [
|
||||
{
|
||||
message: 'Invalid scriptPath: must be a relative path inside the project root, no ".."',
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
else {
|
||||
snakeTargets.push({ script_path: t.scriptPath });
|
||||
}
|
||||
}
|
||||
else if (t.scenePath) {
|
||||
if (!validateSubPath(pv.projectPath, t.scenePath)) {
|
||||
preErrors.set(i, {
|
||||
target: t.scenePath,
|
||||
errors: [
|
||||
{
|
||||
message: 'Invalid scenePath: must be a relative path inside the project root, no ".."',
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
else {
|
||||
snakeTargets.push({ scene_path: t.scenePath });
|
||||
}
|
||||
}
|
||||
else {
|
||||
snakeTargets.push({});
|
||||
}
|
||||
}
|
||||
// Short-circuit when every target failed pre-validation — no work for
|
||||
// Godot, and spawning it would just cost ~3s for a no-op.
|
||||
if (snakeTargets.length === 0 && preErrors.size === targets.length) {
|
||||
const results = targets.map((_, i) => {
|
||||
const pre = preErrors.get(i);
|
||||
return { target: pre.target, valid: false, errors: pre.errors };
|
||||
});
|
||||
return { content: [{ type: 'text', text: JSON.stringify({ results }, null, 2) }] };
|
||||
}
|
||||
const { stdout, stderr } = await runner.executeOperation('validate_batch', { targets: snakeTargets }, pv.projectPath);
|
||||
if (!stdout.trim()) {
|
||||
return createErrorResponse(`Batch validate failed: ${extractGdError(stderr)}`, [
|
||||
'Check that all target paths are valid',
|
||||
'Ensure Godot is installed correctly',
|
||||
]);
|
||||
}
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(stdout.trim());
|
||||
}
|
||||
catch {
|
||||
return createErrorResponse(`Invalid response from validate_batch: ${stdout}`, [
|
||||
'Ensure Godot is installed correctly',
|
||||
]);
|
||||
}
|
||||
const errorsByPath = parseGodotErrorsByPath(stderr || '');
|
||||
const godotResults = parsed.results.map((r) => {
|
||||
const key = r.target.startsWith('res://') ? r.target : `res://${r.target}`;
|
||||
const stderrErrors = errorsByPath.get(key) || errorsByPath.get(r.target) || [];
|
||||
const allErrors = stderrErrors.length > 0 ? stderrErrors : r.errors || [];
|
||||
return {
|
||||
target: r.target,
|
||||
valid: r.valid && stderrErrors.length === 0,
|
||||
errors: allErrors,
|
||||
};
|
||||
});
|
||||
// Merge pre-validation failures back into their original positions so
|
||||
// output order matches input order. Pre-validation errors are ours, not
|
||||
// Godot's — they bypass the stderr overlay above.
|
||||
const results = [];
|
||||
let godotIdx = 0;
|
||||
for (let i = 0; i < targets.length; i++) {
|
||||
if (preErrors.has(i)) {
|
||||
const pre = preErrors.get(i);
|
||||
results.push({ target: pre.target, valid: false, errors: pre.errors });
|
||||
}
|
||||
else {
|
||||
results.push(godotResults[godotIdx++]);
|
||||
}
|
||||
}
|
||||
return { content: [{ type: 'text', text: JSON.stringify({ results }, null, 2) }] };
|
||||
}
|
||||
catch (error) {
|
||||
return createErrorResponse(`Batch validation failed: ${getErrorMessage(error)}`, [
|
||||
'Ensure Godot is installed correctly',
|
||||
'Check if the GODOT_PATH environment variable is set correctly',
|
||||
]);
|
||||
}
|
||||
finally {
|
||||
for (const f of tempFiles) {
|
||||
try {
|
||||
unlinkSync(f);
|
||||
}
|
||||
catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Determine mode — exactly one must be provided
|
||||
const modeCount = [args.scriptPath, args.source, args.scenePath].filter(Boolean).length;
|
||||
if (modeCount === 0) {
|
||||
return createErrorResponse('One of scriptPath, source, or scenePath is required', [
|
||||
'Provide scriptPath to validate an existing .gd file, source to validate inline GDScript, or scenePath to validate a .tscn file',
|
||||
]);
|
||||
}
|
||||
if (modeCount > 1) {
|
||||
return createErrorResponse('Provide exactly one of scriptPath, source, or scenePath — not multiple', ['Only one target can be validated per call']);
|
||||
}
|
||||
let tempFile = false;
|
||||
let resolvedScriptPath;
|
||||
let resolvedScenePath;
|
||||
try {
|
||||
if (args.source) {
|
||||
const { resPath } = writeTempGdScript(pv.projectPath, args.source, 'validate_temp');
|
||||
resolvedScriptPath = resPath;
|
||||
tempFile = true;
|
||||
}
|
||||
else if (args.scriptPath) {
|
||||
if (!validateSubPath(pv.projectPath, args.scriptPath)) {
|
||||
return createErrorResponse('Invalid scriptPath', [
|
||||
'Provide a valid relative path without ".." that stays inside the project directory',
|
||||
]);
|
||||
}
|
||||
const fullPath = join(pv.projectPath, args.scriptPath);
|
||||
if (!existsSync(fullPath)) {
|
||||
return createErrorResponse(`Script file does not exist: ${args.scriptPath}`, [
|
||||
'Ensure the path is correct relative to the project directory',
|
||||
]);
|
||||
}
|
||||
resolvedScriptPath = args.scriptPath;
|
||||
}
|
||||
else if (args.scenePath) {
|
||||
if (!validateSubPath(pv.projectPath, args.scenePath)) {
|
||||
return createErrorResponse('Invalid scenePath', [
|
||||
'Provide a valid relative path without ".." that stays inside the project directory',
|
||||
]);
|
||||
}
|
||||
const fullPath = join(pv.projectPath, args.scenePath);
|
||||
if (!existsSync(fullPath)) {
|
||||
return createErrorResponse(`Scene file does not exist: ${args.scenePath}`, [
|
||||
'Ensure the path is correct relative to the project directory',
|
||||
]);
|
||||
}
|
||||
resolvedScenePath = args.scenePath;
|
||||
}
|
||||
const params = {};
|
||||
if (resolvedScriptPath)
|
||||
params.scriptPath = resolvedScriptPath;
|
||||
if (resolvedScenePath)
|
||||
params.scenePath = resolvedScenePath;
|
||||
const { stdout, stderr } = await runner.executeOperation('validate_resource', params, pv.projectPath);
|
||||
// Parse stdout for the base valid/invalid signal from GDScript
|
||||
let valid = false;
|
||||
let gdErrors = [];
|
||||
try {
|
||||
const parsed = JSON.parse(stdout.trim());
|
||||
valid = parsed.valid === true;
|
||||
if (Array.isArray(parsed.errors) && parsed.errors.length > 0) {
|
||||
gdErrors = parsed.errors;
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// stdout wasn't JSON — treat as invalid
|
||||
valid = false;
|
||||
}
|
||||
// Parse stderr for detailed error messages from Godot's script compiler
|
||||
const stderrErrors = parseGodotErrors(stderr || '');
|
||||
// Merge errors: prefer detailed stderr errors when available, otherwise keep gdErrors
|
||||
const allErrors = stderrErrors.length > 0 ? stderrErrors : gdErrors;
|
||||
// The GDScript-side `valid` flag is unreliable for malformed scripts: load()
|
||||
// returns a non-null placeholder Resource even when parsing fails, so
|
||||
// resource != null is true. Fall back to the parsed stderr errors as the
|
||||
// authoritative signal — matches the batch branch above.
|
||||
const result = {
|
||||
valid: valid && allErrors.length === 0,
|
||||
errors: allErrors,
|
||||
};
|
||||
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
||||
}
|
||||
catch (error) {
|
||||
return createErrorResponse(`Validation failed: ${getErrorMessage(error)}`, [
|
||||
'Ensure Godot is installed correctly',
|
||||
'Check if the GODOT_PATH environment variable is set correctly',
|
||||
]);
|
||||
}
|
||||
finally {
|
||||
if (tempFile && resolvedScriptPath) {
|
||||
const tempFilePath = join(pv.projectPath, resolvedScriptPath);
|
||||
try {
|
||||
unlinkSync(tempFilePath);
|
||||
}
|
||||
catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
//# sourceMappingURL=validate-tools.js.map
|
||||
+1
File diff suppressed because one or more lines are too long
Vendored
+38
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Parsing and editing primitives for the `[autoload]` section of project.godot.
|
||||
*
|
||||
* Used by:
|
||||
* - tools/autoload-tools.ts — list/add/remove/update_autoload handlers
|
||||
* - utils/bridge-manager.ts — McpBridge inject/cleanup/repair
|
||||
*
|
||||
* Pure functions: each takes the absolute path to project.godot and returns
|
||||
* either parsed data or a boolean indicating whether the file was mutated.
|
||||
*/
|
||||
export interface AutoloadEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
singleton: boolean;
|
||||
}
|
||||
/**
|
||||
* Matches an empty `[autoload]` section (the header followed by only blank
|
||||
* lines, up to the next section header or end-of-file). Used by cleanup paths
|
||||
* to drop the section after the last entry is removed.
|
||||
*/
|
||||
export declare const EMPTY_AUTOLOAD_SECTION_REGEX: RegExp;
|
||||
/**
|
||||
* Mirrors the parser's `\w+` assumption (parseAutoloads / removeAutoloadEntry).
|
||||
* Enforced on write paths to prevent a name with newlines or INI section
|
||||
* delimiters from corrupting project.godot.
|
||||
*/
|
||||
export declare const VALID_AUTOLOAD_NAME_REGEX: RegExp;
|
||||
export declare function normalizeAutoloadPath(p: string): string;
|
||||
export declare function parseAutoloads(projectFilePath: string, existingContent?: string): AutoloadEntry[];
|
||||
export declare function addAutoloadEntry(projectFilePath: string, name: string, path: string, singleton: boolean, existingContent?: string): void;
|
||||
/**
|
||||
* Remove the named autoload entry. Also drops the `[autoload]` section header
|
||||
* if the removed entry was the last one in it. Returns true when the file was
|
||||
* mutated.
|
||||
*/
|
||||
export declare function removeAutoloadEntry(projectFilePath: string, name: string): boolean;
|
||||
export declare function updateAutoloadEntry(projectFilePath: string, name: string, newPath?: string, singleton?: boolean): boolean;
|
||||
//# sourceMappingURL=autoload-ini.d.ts.map
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"autoload-ini.d.ts","sourceRoot":"","sources":["../../src/utils/autoload-ini.ts"],"names":[],"mappings":"AAEA;;;;;;;;;GASG;AAEH,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,OAAO,CAAC;CACpB;AAED;;;;GAIG;AACH,eAAO,MAAM,4BAA4B,QAAkC,CAAC;AAE5E;;;;GAIG;AACH,eAAO,MAAM,yBAAyB,QAAU,CAAC;AAUjD,wBAAgB,qBAAqB,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAEvD;AAED,wBAAgB,cAAc,CAAC,eAAe,EAAE,MAAM,EAAE,eAAe,CAAC,EAAE,MAAM,GAAG,aAAa,EAAE,CAsBjG;AAED,wBAAgB,gBAAgB,CAC9B,eAAe,EAAE,MAAM,EACvB,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,EACZ,SAAS,EAAE,OAAO,EAClB,eAAe,CAAC,EAAE,MAAM,GACvB,IAAI,CAkBN;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,eAAe,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CA6BlF;AAED,wBAAgB,mBAAmB,CACjC,eAAe,EAAE,MAAM,EACvB,IAAI,EAAE,MAAM,EACZ,OAAO,CAAC,EAAE,MAAM,EAChB,SAAS,CAAC,EAAE,OAAO,GAClB,OAAO,CA8BT"}
|
||||
Vendored
+124
@@ -0,0 +1,124 @@
|
||||
import { readFileSync, writeFileSync } from 'fs';
|
||||
/**
|
||||
* Matches an empty `[autoload]` section (the header followed by only blank
|
||||
* lines, up to the next section header or end-of-file). Used by cleanup paths
|
||||
* to drop the section after the last entry is removed.
|
||||
*/
|
||||
export const EMPTY_AUTOLOAD_SECTION_REGEX = /\[autoload\]\s*(?=\n\[|\n*$)/g;
|
||||
/**
|
||||
* Mirrors the parser's `\w+` assumption (parseAutoloads / removeAutoloadEntry).
|
||||
* Enforced on write paths to prevent a name with newlines or INI section
|
||||
* delimiters from corrupting project.godot.
|
||||
*/
|
||||
export const VALID_AUTOLOAD_NAME_REGEX = /^\w+$/;
|
||||
function assertValidName(name) {
|
||||
if (!VALID_AUTOLOAD_NAME_REGEX.test(name)) {
|
||||
throw new Error(`Invalid autoload name '${name}': must contain only word characters (letters, digits, underscore)`);
|
||||
}
|
||||
}
|
||||
export function normalizeAutoloadPath(p) {
|
||||
return p.startsWith('res://') ? p : `res://${p}`;
|
||||
}
|
||||
export function parseAutoloads(projectFilePath, existingContent) {
|
||||
const content = existingContent ?? readFileSync(projectFilePath, 'utf8');
|
||||
const autoloads = [];
|
||||
let inAutoloadSection = false;
|
||||
for (const line of content.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith('[')) {
|
||||
inAutoloadSection = trimmed === '[autoload]';
|
||||
continue;
|
||||
}
|
||||
if (!inAutoloadSection || trimmed === '' || trimmed.startsWith(';') || trimmed.startsWith('#'))
|
||||
continue;
|
||||
// The surrounding `"?` are intentional: Godot always writes quotes, but
|
||||
// hand-edited project.godot files sometimes omit them. Tolerating both
|
||||
// shapes means a missing quote pair doesn't silently drop the entry.
|
||||
const match = trimmed.match(/^(\w+)="?(\*?)([^"]*?)"?$/);
|
||||
if (match) {
|
||||
autoloads.push({ name: match[1], singleton: match[2] === '*', path: match[3] });
|
||||
}
|
||||
}
|
||||
return autoloads;
|
||||
}
|
||||
export function addAutoloadEntry(projectFilePath, name, path, singleton, existingContent) {
|
||||
assertValidName(name);
|
||||
const content = existingContent ?? readFileSync(projectFilePath, 'utf8');
|
||||
const lines = content.split('\n');
|
||||
const entry = `${name}="${singleton ? '*' : ''}${normalizeAutoloadPath(path)}"`;
|
||||
const sectionIdx = lines.findIndex((l) => l.trim() === '[autoload]');
|
||||
if (sectionIdx === -1) {
|
||||
writeFileSync(projectFilePath, content.trimEnd() + '\n\n[autoload]\n' + entry + '\n', 'utf8');
|
||||
return;
|
||||
}
|
||||
let insertIdx = sectionIdx + 1;
|
||||
while (insertIdx < lines.length && !lines[insertIdx].trim().startsWith('[')) {
|
||||
insertIdx++;
|
||||
}
|
||||
lines.splice(insertIdx, 0, entry);
|
||||
writeFileSync(projectFilePath, lines.join('\n'), 'utf8');
|
||||
}
|
||||
/**
|
||||
* Remove the named autoload entry. Also drops the `[autoload]` section header
|
||||
* if the removed entry was the last one in it. Returns true when the file was
|
||||
* mutated.
|
||||
*/
|
||||
export function removeAutoloadEntry(projectFilePath, name) {
|
||||
const content = readFileSync(projectFilePath, 'utf8');
|
||||
const lines = content.split('\n');
|
||||
let inAutoloadSection = false;
|
||||
let removed = false;
|
||||
const filtered = lines.filter((line) => {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith('[')) {
|
||||
inAutoloadSection = trimmed === '[autoload]';
|
||||
return true;
|
||||
}
|
||||
if (inAutoloadSection) {
|
||||
const match = trimmed.match(/^(\w+)=/);
|
||||
if (match && match[1] === name) {
|
||||
removed = true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (!removed)
|
||||
return false;
|
||||
let newContent = filtered.join('\n');
|
||||
newContent = newContent.replace(EMPTY_AUTOLOAD_SECTION_REGEX, '');
|
||||
newContent = newContent.trimEnd() + '\n';
|
||||
writeFileSync(projectFilePath, newContent, 'utf8');
|
||||
return true;
|
||||
}
|
||||
export function updateAutoloadEntry(projectFilePath, name, newPath, singleton) {
|
||||
assertValidName(name);
|
||||
const content = readFileSync(projectFilePath, 'utf8');
|
||||
const lines = content.split('\n');
|
||||
let inAutoloadSection = false;
|
||||
let updated = false;
|
||||
const newLines = lines.map((line) => {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith('[')) {
|
||||
inAutoloadSection = trimmed === '[autoload]';
|
||||
return line;
|
||||
}
|
||||
if (inAutoloadSection) {
|
||||
// The surrounding `"?` are intentional: Godot always writes quotes, but
|
||||
// hand-edited project.godot files sometimes omit them. Tolerating both
|
||||
// shapes means a missing quote pair doesn't silently drop the entry.
|
||||
const match = trimmed.match(/^(\w+)="?(\*?)([^"]*?)"?$/);
|
||||
if (match && match[1] === name) {
|
||||
const effectiveSingleton = singleton !== undefined ? singleton : match[2] === '*';
|
||||
const effectivePath = newPath !== undefined ? normalizeAutoloadPath(newPath) : match[3];
|
||||
updated = true;
|
||||
return `${name}="${effectiveSingleton ? '*' : ''}${effectivePath}"`;
|
||||
}
|
||||
}
|
||||
return line;
|
||||
});
|
||||
if (updated)
|
||||
writeFileSync(projectFilePath, newLines.join('\n'), 'utf8');
|
||||
return updated;
|
||||
}
|
||||
//# sourceMappingURL=autoload-ini.js.map
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"autoload-ini.js","sourceRoot":"","sources":["../../src/utils/autoload-ini.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,IAAI,CAAC;AAmBjD;;;;GAIG;AACH,MAAM,CAAC,MAAM,4BAA4B,GAAG,+BAA+B,CAAC;AAE5E;;;;GAIG;AACH,MAAM,CAAC,MAAM,yBAAyB,GAAG,OAAO,CAAC;AAEjD,SAAS,eAAe,CAAC,IAAY;IACnC,IAAI,CAAC,yBAAyB,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC;QAC1C,MAAM,IAAI,KAAK,CACb,0BAA0B,IAAI,oEAAoE,CACnG,CAAC;IACJ,CAAC;AACH,CAAC;AAED,MAAM,UAAU,qBAAqB,CAAC,CAAS;IAC7C,OAAO,CAAC,CAAC,UAAU,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,EAAE,CAAC;AACnD,CAAC;AAED,MAAM,UAAU,cAAc,CAAC,eAAuB,EAAE,eAAwB;IAC9E,MAAM,OAAO,GAAG,eAAe,IAAI,YAAY,CAAC,eAAe,EAAE,MAAM,CAAC,CAAC;IACzE,MAAM,SAAS,GAAoB,EAAE,CAAC;IACtC,IAAI,iBAAiB,GAAG,KAAK,CAAC;IAE9B,KAAK,MAAM,IAAI,IAAI,OAAO,CAAC,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC;QACvC,MAAM,OAAO,GAAG,IAAI,CAAC,IAAI,EAAE,CAAC;QAC5B,IAAI,OAAO,CAAC,UAAU,CAAC,GAAG,CAAC,EAAE,CAAC;YAC5B,iBAAiB,GAAG,OAAO,KAAK,YAAY,CAAC;YAC7C,SAAS;QACX,CAAC;QACD,IAAI,CAAC,iBAAiB,IAAI,OAAO,KAAK,EAAE,IAAI,OAAO,CAAC,UAAU,CAAC,GAAG,CAAC,IAAI,OAAO,CAAC,UAAU,CAAC,GAAG,CAAC;YAC5F,SAAS;QACX,wEAAwE;QACxE,uEAAuE;QACvE,qEAAqE;QACrE,MAAM,KAAK,GAAG,OAAO,CAAC,KAAK,CAAC,2BAA2B,CAAC,CAAC;QACzD,IAAI,KAAK,EAAE,CAAC;YACV,SAAS,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,KAAK,CAAC,CAAC,CAAC,EAAE,SAAS,EAAE,KAAK,CAAC,CAAC,CAAC,KAAK,GAAG,EAAE,IAAI,EAAE,KAAK,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;QAClF,CAAC;IACH,CAAC;IACD,OAAO,SAAS,CAAC;AACnB,CAAC;AAED,MAAM,UAAU,gBAAgB,CAC9B,eAAuB,EACvB,IAAY,EACZ,IAAY,EACZ,SAAkB,EAClB,eAAwB;IAExB,eAAe,CAAC,IAAI,CAAC,CAAC;IACtB,MAAM,OAAO,GAAG,eAAe,IAAI,YAAY,CAAC,eAAe,EAAE,MAAM,CAAC,CAAC;IACzE,MAAM,KAAK,GAAG,OAAO,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;IAClC,MAAM,KAAK,GAAG,GAAG,IAAI,KAAK,SAAS,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,GAAG,qBAAqB,CAAC,IAAI,CAAC,GAAG,CAAC;IAEhF,MAAM,UAAU,GAAG,KAAK,CAAC,SAAS,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,EAAE,KAAK,YAAY,CAAC,CAAC;IACrE,IAAI,UAAU,KAAK,CAAC,CAAC,EAAE,CAAC;QACtB,aAAa,CAAC,eAAe,EAAE,OAAO,CAAC,OAAO,EAAE,GAAG,kBAAkB,GAAG,KAAK,GAAG,IAAI,EAAE,MAAM,CAAC,CAAC;QAC9F,OAAO;IACT,CAAC;IAED,IAAI,SAAS,GAAG,UAAU,GAAG,CAAC,CAAC;IAC/B,OAAO,SAAS,GAAG,KAAK,CAAC,MAAM,IAAI,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC,IAAI,EAAE,CAAC,UAAU,CAAC,GAAG,CAAC,EAAE,CAAC;QAC5E,SAAS,EAAE,CAAC;IACd,CAAC;IACD,KAAK,CAAC,MAAM,CAAC,SAAS,EAAE,CAAC,EAAE,KAAK,CAAC,CAAC;IAClC,aAAa,CAAC,eAAe,EAAE,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC,CAAC;AAC3D,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,mBAAmB,CAAC,eAAuB,EAAE,IAAY;IACvE,MAAM,OAAO,GAAG,YAAY,CAAC,eAAe,EAAE,MAAM,CAAC,CAAC;IACtD,MAAM,KAAK,GAAG,OAAO,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;IAClC,IAAI,iBAAiB,GAAG,KAAK,CAAC;IAC9B,IAAI,OAAO,GAAG,KAAK,CAAC;IAEpB,MAAM,QAAQ,GAAG,KAAK,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE;QACrC,MAAM,OAAO,GAAG,IAAI,CAAC,IAAI,EAAE,CAAC;QAC5B,IAAI,OAAO,CAAC,UAAU,CAAC,GAAG,CAAC,EAAE,CAAC;YAC5B,iBAAiB,GAAG,OAAO,KAAK,YAAY,CAAC;YAC7C,OAAO,IAAI,CAAC;QACd,CAAC;QACD,IAAI,iBAAiB,EAAE,CAAC;YACtB,MAAM,KAAK,GAAG,OAAO,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC;YACvC,IAAI,KAAK,IAAI,KAAK,CAAC,CAAC,CAAC,KAAK,IAAI,EAAE,CAAC;gBAC/B,OAAO,GAAG,IAAI,CAAC;gBACf,OAAO,KAAK,CAAC;YACf,CAAC;QACH,CAAC;QACD,OAAO,IAAI,CAAC;IACd,CAAC,CAAC,CAAC;IAEH,IAAI,CAAC,OAAO;QAAE,OAAO,KAAK,CAAC;IAE3B,IAAI,UAAU,GAAG,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACrC,UAAU,GAAG,UAAU,CAAC,OAAO,CAAC,4BAA4B,EAAE,EAAE,CAAC,CAAC;IAClE,UAAU,GAAG,UAAU,CAAC,OAAO,EAAE,GAAG,IAAI,CAAC;IACzC,aAAa,CAAC,eAAe,EAAE,UAAU,EAAE,MAAM,CAAC,CAAC;IACnD,OAAO,IAAI,CAAC;AACd,CAAC;AAED,MAAM,UAAU,mBAAmB,CACjC,eAAuB,EACvB,IAAY,EACZ,OAAgB,EAChB,SAAmB;IAEnB,eAAe,CAAC,IAAI,CAAC,CAAC;IACtB,MAAM,OAAO,GAAG,YAAY,CAAC,eAAe,EAAE,MAAM,CAAC,CAAC;IACtD,MAAM,KAAK,GAAG,OAAO,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;IAClC,IAAI,iBAAiB,GAAG,KAAK,CAAC;IAC9B,IAAI,OAAO,GAAG,KAAK,CAAC;IAEpB,MAAM,QAAQ,GAAG,KAAK,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE;QAClC,MAAM,OAAO,GAAG,IAAI,CAAC,IAAI,EAAE,CAAC;QAC5B,IAAI,OAAO,CAAC,UAAU,CAAC,GAAG,CAAC,EAAE,CAAC;YAC5B,iBAAiB,GAAG,OAAO,KAAK,YAAY,CAAC;YAC7C,OAAO,IAAI,CAAC;QACd,CAAC;QACD,IAAI,iBAAiB,EAAE,CAAC;YACtB,wEAAwE;YACxE,uEAAuE;YACvE,qEAAqE;YACrE,MAAM,KAAK,GAAG,OAAO,CAAC,KAAK,CAAC,2BAA2B,CAAC,CAAC;YACzD,IAAI,KAAK,IAAI,KAAK,CAAC,CAAC,CAAC,KAAK,IAAI,EAAE,CAAC;gBAC/B,MAAM,kBAAkB,GAAG,SAAS,KAAK,SAAS,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,KAAK,GAAG,CAAC;gBAClF,MAAM,aAAa,GAAG,OAAO,KAAK,SAAS,CAAC,CAAC,CAAC,qBAAqB,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;gBACxF,OAAO,GAAG,IAAI,CAAC;gBACf,OAAO,GAAG,IAAI,KAAK,kBAAkB,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,GAAG,aAAa,GAAG,CAAC;YACtE,CAAC;QACH,CAAC;QACD,OAAO,IAAI,CAAC;IACd,CAAC,CAAC,CAAC;IAEH,IAAI,OAAO;QAAE,aAAa,CAAC,eAAe,EAAE,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC,CAAC;IACzE,OAAO,OAAO,CAAC;AACjB,CAAC"}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Owns the McpBridge autoload artifact: the script copy in the target project,
|
||||
* the `[autoload]` entry in project.godot, the `.mcp/.gdignore` marker, and the
|
||||
* `.gitignore` augmentation. GodotRunner delegates to this for inject/cleanup
|
||||
* during run_project / attach_project / stop_project flows.
|
||||
*
|
||||
* The project-root bridge script is runtime-owned and refreshed on first
|
||||
* injection for a manager session so a rebuilt server cannot talk to stale
|
||||
* GDScript from an earlier run. Idempotent within a session via
|
||||
* `injectedProjects`: a second `inject()` call for the same path short-circuits
|
||||
* without rewriting project.godot.
|
||||
*/
|
||||
export declare class BridgeManager {
|
||||
private bridgeScriptPath;
|
||||
private injectedProjects;
|
||||
private repairedProjects;
|
||||
/**
|
||||
* Last port baked into the on-disk script per project. Used by the
|
||||
* race-detection helper in runtime-tools to spot a concurrent re-inject
|
||||
* after a bridge-wait timeout.
|
||||
*/
|
||||
private lastInjectedPort;
|
||||
constructor(bridgeScriptPath: string);
|
||||
inject(projectPath: string, port: number): void;
|
||||
cleanup(projectPath: string): void;
|
||||
/**
|
||||
* Read the port currently baked into the project's bridge script. Returns
|
||||
* the integer on success, or null if the file is missing, unreadable, or
|
||||
* lacks the marker. Used to detect concurrent re-inject after a bridge-
|
||||
* wait timeout (another MCP client may have rewritten the port).
|
||||
*/
|
||||
readBakedPort(projectPath: string): number | null;
|
||||
/**
|
||||
* If project.godot still has an `McpBridge=` line but the script file is
|
||||
* missing, the autoload would crash every subsequent headless op. Detect and
|
||||
* clean the orphan before running an operation.
|
||||
*
|
||||
* Cached per project: once a path has been checked clean, skip the file
|
||||
* reads on subsequent ops in the same session.
|
||||
*/
|
||||
repairOrphaned(projectPath: string): void;
|
||||
private removeBridgeArtifacts;
|
||||
private ensureMcpGdignore;
|
||||
private ensureGitignored;
|
||||
}
|
||||
//# sourceMappingURL=bridge-manager.d.ts.map
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"bridge-manager.d.ts","sourceRoot":"","sources":["../../src/utils/bridge-manager.ts"],"names":[],"mappings":"AAaA;;;;;;;;;;;GAWG;AACH,qBAAa,aAAa;IAUZ,OAAO,CAAC,gBAAgB;IATpC,OAAO,CAAC,gBAAgB,CAA0B;IAClD,OAAO,CAAC,gBAAgB,CAA0B;IAClD;;;;OAIG;IACH,OAAO,CAAC,gBAAgB,CAAkC;gBAEtC,gBAAgB,EAAE,MAAM;IAE5C,MAAM,CAAC,WAAW,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI;IAuC/C,OAAO,CAAC,WAAW,EAAE,MAAM,GAAG,IAAI;IAOlC;;;;;OAKG;IACH,aAAa,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI;IAejD;;;;;;;OAOG;IACH,cAAc,CAAC,WAAW,EAAE,MAAM,GAAG,IAAI;IAqBzC,OAAO,CAAC,qBAAqB;IAkC7B,OAAO,CAAC,iBAAiB;IAOzB,OAAO,CAAC,gBAAgB;CAkBzB"}
|
||||
Vendored
+186
@@ -0,0 +1,186 @@
|
||||
import { join } from 'path';
|
||||
import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from 'fs';
|
||||
import { logDebug } from './logger.js';
|
||||
import { addAutoloadEntry, parseAutoloads, removeAutoloadEntry } from './autoload-ini.js';
|
||||
const BRIDGE_AUTOLOAD_NAME = 'McpBridge';
|
||||
const BRIDGE_SCRIPT_FILENAME = 'mcp_bridge.gd';
|
||||
const MCP_GITIGNORE_ENTRY = '.mcp/';
|
||||
// Matches the baked-port marker line inserted in src/scripts/mcp_bridge.gd —
|
||||
// `const PORT := <int>` — so inject() can rewrite the integer per project.
|
||||
const BAKED_PORT_REGEX = /const PORT := \d+/;
|
||||
/**
|
||||
* Owns the McpBridge autoload artifact: the script copy in the target project,
|
||||
* the `[autoload]` entry in project.godot, the `.mcp/.gdignore` marker, and the
|
||||
* `.gitignore` augmentation. GodotRunner delegates to this for inject/cleanup
|
||||
* during run_project / attach_project / stop_project flows.
|
||||
*
|
||||
* The project-root bridge script is runtime-owned and refreshed on first
|
||||
* injection for a manager session so a rebuilt server cannot talk to stale
|
||||
* GDScript from an earlier run. Idempotent within a session via
|
||||
* `injectedProjects`: a second `inject()` call for the same path short-circuits
|
||||
* without rewriting project.godot.
|
||||
*/
|
||||
export class BridgeManager {
|
||||
bridgeScriptPath;
|
||||
injectedProjects = new Set();
|
||||
repairedProjects = new Set();
|
||||
/**
|
||||
* Last port baked into the on-disk script per project. Used by the
|
||||
* race-detection helper in runtime-tools to spot a concurrent re-inject
|
||||
* after a bridge-wait timeout.
|
||||
*/
|
||||
lastInjectedPort = new Map();
|
||||
constructor(bridgeScriptPath) {
|
||||
this.bridgeScriptPath = bridgeScriptPath;
|
||||
}
|
||||
inject(projectPath, port) {
|
||||
// Always rewrite the destination — the per-project bridge script may
|
||||
// differ from the template by exactly the baked integer, so a size/mtime
|
||||
// shortcut no longer maps to "up-to-date." Bake the resolved port into
|
||||
// the const PORT line so the running game listens on the exact port the
|
||||
// Node side will connect to.
|
||||
const template = readFileSync(this.bridgeScriptPath, 'utf8');
|
||||
if (!BAKED_PORT_REGEX.test(template)) {
|
||||
throw new Error(`Bridge script template at ${this.bridgeScriptPath} is missing the 'const PORT := <int>' marker`);
|
||||
}
|
||||
const baked = template.replace(BAKED_PORT_REGEX, `const PORT := ${port}`);
|
||||
const destScript = join(projectPath, BRIDGE_SCRIPT_FILENAME);
|
||||
writeFileSync(destScript, baked, 'utf8');
|
||||
this.lastInjectedPort.set(projectPath, port);
|
||||
logDebug(`Wrote bridge autoload at ${destScript} (baked port ${port})`);
|
||||
if (this.injectedProjects.has(projectPath)) {
|
||||
logDebug('Bridge already injected for this project; refreshed script only.');
|
||||
return;
|
||||
}
|
||||
this.ensureMcpGdignore(projectPath);
|
||||
this.ensureGitignored(projectPath);
|
||||
const projectFile = join(projectPath, 'project.godot');
|
||||
const existing = parseAutoloads(projectFile);
|
||||
const alreadyRegistered = existing.some((a) => a.name === BRIDGE_AUTOLOAD_NAME);
|
||||
if (alreadyRegistered) {
|
||||
logDebug('Bridge autoload already present, skipping injection');
|
||||
}
|
||||
else {
|
||||
addAutoloadEntry(projectFile, BRIDGE_AUTOLOAD_NAME, BRIDGE_SCRIPT_FILENAME, true);
|
||||
logDebug('Injected bridge autoload into project.godot');
|
||||
}
|
||||
this.injectedProjects.add(projectPath);
|
||||
}
|
||||
cleanup(projectPath) {
|
||||
this.removeBridgeArtifacts(projectPath);
|
||||
this.injectedProjects.delete(projectPath);
|
||||
this.repairedProjects.delete(projectPath);
|
||||
this.lastInjectedPort.delete(projectPath);
|
||||
}
|
||||
/**
|
||||
* Read the port currently baked into the project's bridge script. Returns
|
||||
* the integer on success, or null if the file is missing, unreadable, or
|
||||
* lacks the marker. Used to detect concurrent re-inject after a bridge-
|
||||
* wait timeout (another MCP client may have rewritten the port).
|
||||
*/
|
||||
readBakedPort(projectPath) {
|
||||
const destScript = join(projectPath, BRIDGE_SCRIPT_FILENAME);
|
||||
if (!existsSync(destScript))
|
||||
return null;
|
||||
try {
|
||||
const content = readFileSync(destScript, 'utf8');
|
||||
const match = content.match(BAKED_PORT_REGEX);
|
||||
if (!match)
|
||||
return null;
|
||||
const parsed = Number.parseInt(match[0].replace(/^const PORT := /, ''), 10);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0 || parsed > 65535)
|
||||
return null;
|
||||
return parsed;
|
||||
}
|
||||
catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* If project.godot still has an `McpBridge=` line but the script file is
|
||||
* missing, the autoload would crash every subsequent headless op. Detect and
|
||||
* clean the orphan before running an operation.
|
||||
*
|
||||
* Cached per project: once a path has been checked clean, skip the file
|
||||
* reads on subsequent ops in the same session.
|
||||
*/
|
||||
repairOrphaned(projectPath) {
|
||||
if (this.repairedProjects.has(projectPath))
|
||||
return;
|
||||
const projectFile = join(projectPath, 'project.godot');
|
||||
const bridgeScript = join(projectPath, BRIDGE_SCRIPT_FILENAME);
|
||||
if (!existsSync(projectFile))
|
||||
return;
|
||||
if (existsSync(bridgeScript)) {
|
||||
this.repairedProjects.add(projectPath);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const content = readFileSync(projectFile, 'utf8');
|
||||
if (content.includes(`${BRIDGE_AUTOLOAD_NAME}=`)) {
|
||||
this.removeBridgeArtifacts(projectPath);
|
||||
logDebug('Cleaned up orphaned McpBridge autoload entry');
|
||||
}
|
||||
this.repairedProjects.add(projectPath);
|
||||
}
|
||||
catch (err) {
|
||||
logDebug(`Non-fatal: Failed to check/repair orphaned bridge: ${err}`);
|
||||
}
|
||||
}
|
||||
removeBridgeArtifacts(projectPath) {
|
||||
try {
|
||||
const projectFile = join(projectPath, 'project.godot');
|
||||
if (existsSync(projectFile)) {
|
||||
const removed = removeAutoloadEntry(projectFile, BRIDGE_AUTOLOAD_NAME);
|
||||
if (removed) {
|
||||
logDebug(`Removed ${BRIDGE_AUTOLOAD_NAME} autoload from project.godot`);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
logDebug(`Non-fatal: Failed to clean ${BRIDGE_AUTOLOAD_NAME} from project.godot: ${err}`);
|
||||
}
|
||||
try {
|
||||
const scriptFile = join(projectPath, BRIDGE_SCRIPT_FILENAME);
|
||||
if (existsSync(scriptFile)) {
|
||||
unlinkSync(scriptFile);
|
||||
logDebug(`Removed ${BRIDGE_SCRIPT_FILENAME} from project`);
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
logDebug(`Non-fatal: Failed to remove ${BRIDGE_SCRIPT_FILENAME}: ${err}`);
|
||||
}
|
||||
try {
|
||||
const uidFile = join(projectPath, `${BRIDGE_SCRIPT_FILENAME}.uid`);
|
||||
if (existsSync(uidFile)) {
|
||||
unlinkSync(uidFile);
|
||||
logDebug(`Removed ${BRIDGE_SCRIPT_FILENAME}.uid from project`);
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
logDebug(`Non-fatal: Failed to remove ${BRIDGE_SCRIPT_FILENAME}.uid: ${err}`);
|
||||
}
|
||||
}
|
||||
ensureMcpGdignore(projectPath) {
|
||||
const mcpDir = join(projectPath, '.mcp');
|
||||
mkdirSync(mcpDir, { recursive: true });
|
||||
writeFileSync(join(mcpDir, '.gdignore'), '', 'utf8');
|
||||
logDebug('Created .mcp/.gdignore');
|
||||
}
|
||||
ensureGitignored(projectPath) {
|
||||
const gitignorePath = join(projectPath, '.gitignore');
|
||||
if (existsSync(gitignorePath)) {
|
||||
const gitignoreContent = readFileSync(gitignorePath, 'utf8');
|
||||
if (!gitignoreContent.includes(MCP_GITIGNORE_ENTRY)) {
|
||||
const newline = gitignoreContent.endsWith('\n') ? '' : '\n';
|
||||
writeFileSync(gitignorePath, gitignoreContent + newline + MCP_GITIGNORE_ENTRY + '\n', 'utf8');
|
||||
logDebug('Added .mcp/ to existing .gitignore');
|
||||
}
|
||||
}
|
||||
else {
|
||||
writeFileSync(gitignorePath, MCP_GITIGNORE_ENTRY + '\n', 'utf8');
|
||||
logDebug('Created .gitignore with .mcp/ entry');
|
||||
}
|
||||
}
|
||||
}
|
||||
//# sourceMappingURL=bridge-manager.js.map
|
||||
+1
File diff suppressed because one or more lines are too long
+37
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Wire format shared between the Node-side `GodotRunner.sendCommand` and the
|
||||
* GDScript-side `McpBridge` autoload.
|
||||
*
|
||||
* KEEP IN SYNC: src/scripts/mcp_bridge.gd implements the same framing on the
|
||||
* Godot side. Any change here MUST be mirrored there (and vice versa).
|
||||
*
|
||||
* Frame: 4-byte big-endian length prefix + UTF-8 JSON payload.
|
||||
* Max frame size is 16 MiB; oversize frames are rejected on receive.
|
||||
*/
|
||||
export declare const DEFAULT_BRIDGE_PORT = 9900;
|
||||
export declare const MAX_FRAME_BYTES: number;
|
||||
export declare const FRAME_HEADER_BYTES = 4;
|
||||
/**
|
||||
* Find an available TCP port by binding to port 0 (OS-assigned ephemeral port),
|
||||
* reading the assigned port, and closing the listener. The brief TOCTOU window
|
||||
* between close and the consumer's listen is acceptable — if a collision occurs,
|
||||
* the bridge readiness check will surface the failure.
|
||||
*/
|
||||
export declare function findFreePort(): Promise<number>;
|
||||
/**
|
||||
* Encode a JSON string as a length-prefixed frame.
|
||||
*/
|
||||
export declare function encodeFrame(payload: string): Buffer;
|
||||
export interface ParseFramesResult {
|
||||
frames: Buffer[];
|
||||
remainder: Buffer;
|
||||
}
|
||||
/**
|
||||
* Pull as many complete frames as possible from a streaming buffer. Any
|
||||
* partial frame at the tail is returned as `remainder` for the next call.
|
||||
*
|
||||
* Throws if a header advertises a payload larger than {@link MAX_FRAME_BYTES} —
|
||||
* the caller should treat this as a fatal protocol error and close the socket.
|
||||
*/
|
||||
export declare function parseFrames(buffer: Buffer): ParseFramesResult;
|
||||
//# sourceMappingURL=bridge-protocol.d.ts.map
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"bridge-protocol.d.ts","sourceRoot":"","sources":["../../src/utils/bridge-protocol.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAIH,eAAO,MAAM,mBAAmB,OAAO,CAAC;AACxC,eAAO,MAAM,eAAe,QAAmB,CAAC;AAChD,eAAO,MAAM,kBAAkB,IAAI,CAAC;AAEpC;;;;;GAKG;AACH,wBAAgB,YAAY,IAAI,OAAO,CAAC,MAAM,CAAC,CAkB9C;AAED;;GAEG;AACH,wBAAgB,WAAW,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CASnD;AAED,MAAM,WAAW,iBAAiB;IAChC,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;;;;;GAMG;AACH,wBAAgB,WAAW,CAAC,MAAM,EAAE,MAAM,GAAG,iBAAiB,CAoB7D"}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Wire format shared between the Node-side `GodotRunner.sendCommand` and the
|
||||
* GDScript-side `McpBridge` autoload.
|
||||
*
|
||||
* KEEP IN SYNC: src/scripts/mcp_bridge.gd implements the same framing on the
|
||||
* Godot side. Any change here MUST be mirrored there (and vice versa).
|
||||
*
|
||||
* Frame: 4-byte big-endian length prefix + UTF-8 JSON payload.
|
||||
* Max frame size is 16 MiB; oversize frames are rejected on receive.
|
||||
*/
|
||||
import * as net from 'net';
|
||||
export const DEFAULT_BRIDGE_PORT = 9900;
|
||||
export const MAX_FRAME_BYTES = 16 * 1024 * 1024;
|
||||
export const FRAME_HEADER_BYTES = 4;
|
||||
/**
|
||||
* Find an available TCP port by binding to port 0 (OS-assigned ephemeral port),
|
||||
* reading the assigned port, and closing the listener. The brief TOCTOU window
|
||||
* between close and the consumer's listen is acceptable — if a collision occurs,
|
||||
* the bridge readiness check will surface the failure.
|
||||
*/
|
||||
export function findFreePort() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const srv = net.createServer();
|
||||
srv.listen(0, '127.0.0.1', () => {
|
||||
const addr = srv.address();
|
||||
if (!addr || typeof addr === 'string') {
|
||||
srv.close();
|
||||
reject(new Error('Failed to determine assigned port'));
|
||||
return;
|
||||
}
|
||||
const port = addr.port;
|
||||
srv.close(() => resolve(port));
|
||||
});
|
||||
// One-shot: only fires before listen() succeeds. If listen succeeded,
|
||||
// we proceed to srv.close() in the listening callback — a later error
|
||||
// is not possible from this server, so the listener stays safely dormant.
|
||||
srv.on('error', reject);
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Encode a JSON string as a length-prefixed frame.
|
||||
*/
|
||||
export function encodeFrame(payload) {
|
||||
const body = Buffer.from(payload, 'utf8');
|
||||
if (body.length > MAX_FRAME_BYTES) {
|
||||
throw new Error(`Bridge frame too large: ${body.length} bytes (limit ${MAX_FRAME_BYTES})`);
|
||||
}
|
||||
const frame = Buffer.allocUnsafe(FRAME_HEADER_BYTES + body.length);
|
||||
frame.writeUInt32BE(body.length, 0);
|
||||
body.copy(frame, FRAME_HEADER_BYTES);
|
||||
return frame;
|
||||
}
|
||||
/**
|
||||
* Pull as many complete frames as possible from a streaming buffer. Any
|
||||
* partial frame at the tail is returned as `remainder` for the next call.
|
||||
*
|
||||
* Throws if a header advertises a payload larger than {@link MAX_FRAME_BYTES} —
|
||||
* the caller should treat this as a fatal protocol error and close the socket.
|
||||
*/
|
||||
export function parseFrames(buffer) {
|
||||
const frames = [];
|
||||
let offset = 0;
|
||||
while (buffer.length - offset >= FRAME_HEADER_BYTES) {
|
||||
const len = buffer.readUInt32BE(offset);
|
||||
if (len > MAX_FRAME_BYTES) {
|
||||
throw new Error(`Bridge frame header advertises ${len} bytes, exceeds limit ${MAX_FRAME_BYTES}`);
|
||||
}
|
||||
const frameStart = offset + FRAME_HEADER_BYTES;
|
||||
const frameEnd = frameStart + len;
|
||||
if (buffer.length < frameEnd)
|
||||
break;
|
||||
frames.push(buffer.subarray(frameStart, frameEnd));
|
||||
offset = frameEnd;
|
||||
}
|
||||
const remainder = offset === 0 ? buffer : buffer.subarray(offset);
|
||||
return { frames, remainder };
|
||||
}
|
||||
//# sourceMappingURL=bridge-protocol.js.map
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"bridge-protocol.js","sourceRoot":"","sources":["../../src/utils/bridge-protocol.ts"],"names":[],"mappings":"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"}
|
||||
Vendored
+248
@@ -0,0 +1,248 @@
|
||||
import type { ChildProcess } from 'child_process';
|
||||
/**
|
||||
* 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 declare class BridgeDisconnectedError extends Error {
|
||||
constructor(message: string);
|
||||
}
|
||||
export declare const BRIDGE_WAIT_SPAWNED_TIMEOUT_MS = 8000;
|
||||
/**
|
||||
* 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 declare function normalizeForCompare(p: string): string;
|
||||
/**
|
||||
* 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 declare function extractJson(output: string): string;
|
||||
/**
|
||||
* Strip Godot banner and debug lines from output, keeping only meaningful content.
|
||||
*/
|
||||
export declare function cleanOutput(output: string): string;
|
||||
export declare function cleanStdout(stdout: string): string;
|
||||
export interface GodotProcess {
|
||||
process: ChildProcess;
|
||||
output: string[];
|
||||
errors: string[];
|
||||
totalErrorsWritten: number;
|
||||
exitCode: number | null;
|
||||
hasExited: boolean;
|
||||
sessionToken: string;
|
||||
}
|
||||
export type RuntimeSessionMode = 'spawned' | 'attached';
|
||||
export interface RuntimeStopResult {
|
||||
mode: RuntimeSessionMode;
|
||||
output: string[];
|
||||
errors: string[];
|
||||
externalProcessPreserved?: boolean;
|
||||
}
|
||||
export interface GodotServerConfig {
|
||||
godotPath?: string;
|
||||
debugMode?: boolean;
|
||||
}
|
||||
export interface OperationParams {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
export interface OperationResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
export interface ToolAnnotations {
|
||||
readOnlyHint?: boolean;
|
||||
destructiveHint?: boolean;
|
||||
idempotentHint?: boolean;
|
||||
openWorldHint?: boolean;
|
||||
title?: string;
|
||||
}
|
||||
export interface ToolDefinition {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: {
|
||||
type: string;
|
||||
properties: Record<string, unknown>;
|
||||
required: string[];
|
||||
};
|
||||
outputSchema?: {
|
||||
type: string;
|
||||
properties?: Record<string, unknown>;
|
||||
required?: string[];
|
||||
};
|
||||
annotations?: ToolAnnotations;
|
||||
}
|
||||
export interface ToolResponse {
|
||||
content: Array<{
|
||||
type: string;
|
||||
text?: string;
|
||||
[k: string]: unknown;
|
||||
}>;
|
||||
isError?: boolean;
|
||||
structuredContent?: Record<string, unknown>;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
export type ToolHandler = (runner: GodotRunner, args: OperationParams) => Promise<ToolResponse> | ToolResponse;
|
||||
export declare function normalizeParameters(params: OperationParams): OperationParams;
|
||||
export declare function convertCamelToSnakeCase(params: OperationParams): OperationParams;
|
||||
/**
|
||||
* 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 declare function checkDisplayAvailable(): boolean;
|
||||
export declare function validatePath(path: string): boolean;
|
||||
/**
|
||||
* 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 declare function validateSubPath(projectPath: string, userPath: string): boolean;
|
||||
/**
|
||||
* 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 declare function validateNodePath(path: string): boolean;
|
||||
/**
|
||||
* 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 declare function isUnderDir(parent: string, child: string): boolean;
|
||||
/**
|
||||
* 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 declare function getErrorMessage(error: unknown): string;
|
||||
/**
|
||||
* Build the absolute path to a project's `project.godot` manifest. Use this
|
||||
* instead of `join(dir, 'project.godot')` ad hoc.
|
||||
*/
|
||||
export declare function projectGodotPath(projectDir: string): string;
|
||||
/**
|
||||
* Extract the first [ERROR] message from GDScript stderr output.
|
||||
* Falls back to a generic message if no [ERROR] line is found.
|
||||
*/
|
||||
export declare function extractGdError(stderr: string): string;
|
||||
export declare function createErrorResponse(message: string, possibleSolutions?: string[]): {
|
||||
content: Array<{
|
||||
type: 'text';
|
||||
text: string;
|
||||
}>;
|
||||
isError: boolean;
|
||||
};
|
||||
/**
|
||||
* 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 declare function createStructuredResponse<T extends Record<string, unknown>>(payload: T, extraContent?: Array<{
|
||||
type: string;
|
||||
[k: string]: unknown;
|
||||
}>): ToolResponse;
|
||||
interface ValidatedProjectArgs {
|
||||
projectPath: string;
|
||||
}
|
||||
interface ValidatedSceneArgs {
|
||||
projectPath: string;
|
||||
scenePath: string;
|
||||
}
|
||||
type ValidationErrorResult = ReturnType<typeof createErrorResponse>;
|
||||
export declare function validateProjectArgs(args: OperationParams): ValidatedProjectArgs | ValidationErrorResult;
|
||||
export declare function validateSceneArgs(args: OperationParams, opts?: {
|
||||
sceneRequired?: boolean;
|
||||
}): ValidatedSceneArgs | ValidationErrorResult;
|
||||
export declare class GodotRunner {
|
||||
private godotPath;
|
||||
private operationsScriptPath;
|
||||
private bridge;
|
||||
private validatedPaths;
|
||||
private cachedVersion;
|
||||
activeProcess: GodotProcess | null;
|
||||
activeProjectPath: string | null;
|
||||
activeSessionMode: RuntimeSessionMode | null;
|
||||
activeBridgePort: number | null;
|
||||
private socket;
|
||||
private rxChunks;
|
||||
private rxTotal;
|
||||
private inFlight;
|
||||
constructor(config?: GodotServerConfig);
|
||||
private isValidGodotPathSync;
|
||||
private spawnAsync;
|
||||
private isValidGodotPath;
|
||||
detectGodotPath(): Promise<void>;
|
||||
getGodotPath(): string | null;
|
||||
/**
|
||||
* 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: string): number | null;
|
||||
getVersion(): Promise<string>;
|
||||
executeOperation(operation: string, params: OperationParams, projectPath: string, timeoutMs?: number): Promise<OperationResult>;
|
||||
launchEditor(projectPath: string): ChildProcess;
|
||||
runProject(projectPath: string, scene?: string, background?: boolean, bridgePort?: number): Promise<GodotProcess>;
|
||||
attachProject(projectPath: string, bridgePort?: number): Promise<void>;
|
||||
stopProject(): Promise<RuntimeStopResult | null>;
|
||||
hasActiveRuntimeSession(): boolean;
|
||||
/**
|
||||
* 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: string, params?: Record<string, unknown>, timeoutMs?: number): Promise<string>;
|
||||
/**
|
||||
* Tear down the bridge socket. Idempotent. Any in-flight command is
|
||||
* rejected with a session-ended error.
|
||||
*/
|
||||
closeConnection(): void;
|
||||
private resetRxBuffer;
|
||||
getErrorCount(): number;
|
||||
getErrorsSince(marker: number): string[];
|
||||
private static readonly SCRIPT_ERROR_PATTERNS;
|
||||
private static readonly RETRYABLE_BRIDGE_COMMANDS;
|
||||
extractRuntimeErrors(lines: string[]): string[];
|
||||
private sendCommandWithReconnect;
|
||||
sendCommandWithErrors(command: string, params?: Record<string, unknown>, timeoutMs?: number): Promise<{
|
||||
response: string;
|
||||
runtimeErrors: string[];
|
||||
}>;
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
private pollBridge;
|
||||
waitForBridgeAttached(timeoutMs?: number, intervalMs?: number): Promise<{
|
||||
ready: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
waitForBridge(timeoutMs?: number, intervalMs?: number): Promise<{
|
||||
ready: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
getRecentErrors(count?: number): string[];
|
||||
}
|
||||
export {};
|
||||
//# sourceMappingURL=godot-runner.d.ts.map
|
||||
+1
File diff suppressed because one or more lines are too long
Vendored
+1119
File diff suppressed because it is too large
Load Diff
+1
File diff suppressed because one or more lines are too long
+36
@@ -0,0 +1,36 @@
|
||||
import type { GodotRunner, OperationParams, ToolResponse } from './godot-runner.js';
|
||||
export declare const MAX_RUNTIME_ERROR_CONTEXT_LINES = 30;
|
||||
/**
|
||||
* Wraps the execute + empty-stdout-check + try/catch around a headless GDScript
|
||||
* operation. Used by the 15 scene/node mutation handlers in tools/scene-tools.ts
|
||||
* and tools/node-tools.ts to eliminate identical error-handling duplication.
|
||||
*
|
||||
* Handlers retain control of: parameter normalization, project/scene validation,
|
||||
* field validation, and constructing the `params` object — those run before the
|
||||
* call. Success-shape construction (the JSON wrapping the GDScript stdout) is
|
||||
* also unchanged: this helper just returns `{ content: [{ type: 'text', text: stdout }] }`,
|
||||
* which is the exact shape every handler produced previously.
|
||||
*/
|
||||
export declare function executeSceneOp(runner: GodotRunner, operation: string, params: OperationParams, projectPath: string, failurePrefix: string, emptyStdoutSolutions: string[], exceptionSolutions?: string[], options?: {
|
||||
parseStdoutAsJson?: boolean;
|
||||
}): Promise<ToolResponse>;
|
||||
export type ParseResult<T> = {
|
||||
ok: true;
|
||||
data: T;
|
||||
} | {
|
||||
ok: false;
|
||||
response: ToolResponse;
|
||||
};
|
||||
/**
|
||||
* Parse a JSON frame returned by the McpBridge. On failure, returns a
|
||||
* structured error response so handlers can short-circuit with one branch.
|
||||
* `context` should describe which bridge command produced the frame.
|
||||
*/
|
||||
export declare function parseBridgeJson<T = unknown>(responseStr: string, context: string): ParseResult<T>;
|
||||
/**
|
||||
* Attach captured runtime errors as a `warnings` array on a tool response
|
||||
* payload. No-op when there are no runtime errors. Truncates to
|
||||
* `MAX_RUNTIME_ERROR_CONTEXT_LINES` to keep payloads bounded.
|
||||
*/
|
||||
export declare function attachRuntimeWarnings(target: Record<string, unknown>, runtimeErrors: string[]): void;
|
||||
//# sourceMappingURL=handler-helpers.d.ts.map
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"handler-helpers.d.ts","sourceRoot":"","sources":["../../src/utils/handler-helpers.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,eAAe,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAQpF,eAAO,MAAM,+BAA+B,KAAK,CAAC;AAElD;;;;;;;;;;GAUG;AACH,wBAAsB,cAAc,CAClC,MAAM,EAAE,WAAW,EACnB,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,eAAe,EACvB,WAAW,EAAE,MAAM,EACnB,aAAa,EAAE,MAAM,EACrB,oBAAoB,EAAE,MAAM,EAAE,EAC9B,kBAAkB,GAAE,MAAM,EAA4C,EACtE,OAAO,GAAE;IAAE,iBAAiB,CAAC,EAAE,OAAO,CAAA;CAAO,GAC5C,OAAO,CAAC,YAAY,CAAC,CA2BvB;AAED,MAAM,MAAM,WAAW,CAAC,CAAC,IAAI;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,IAAI,EAAE,CAAC,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,QAAQ,EAAE,YAAY,CAAA;CAAE,CAAC;AAE3F;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,CAAC,GAAG,OAAO,EAAE,WAAW,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,WAAW,CAAC,CAAC,CAAC,CAejG;AAED;;;;GAIG;AACH,wBAAgB,qBAAqB,CACnC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/B,aAAa,EAAE,MAAM,EAAE,GACtB,IAAI,CAIN"}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
import { createErrorResponse, createStructuredResponse, extractGdError, getErrorMessage, } from './godot-runner.js';
|
||||
export const MAX_RUNTIME_ERROR_CONTEXT_LINES = 30;
|
||||
/**
|
||||
* Wraps the execute + empty-stdout-check + try/catch around a headless GDScript
|
||||
* operation. Used by the 15 scene/node mutation handlers in tools/scene-tools.ts
|
||||
* and tools/node-tools.ts to eliminate identical error-handling duplication.
|
||||
*
|
||||
* Handlers retain control of: parameter normalization, project/scene validation,
|
||||
* field validation, and constructing the `params` object — those run before the
|
||||
* call. Success-shape construction (the JSON wrapping the GDScript stdout) is
|
||||
* also unchanged: this helper just returns `{ content: [{ type: 'text', text: stdout }] }`,
|
||||
* which is the exact shape every handler produced previously.
|
||||
*/
|
||||
export async function executeSceneOp(runner, operation, params, projectPath, failurePrefix, emptyStdoutSolutions, exceptionSolutions = ['Ensure Godot is installed correctly'], options = {}) {
|
||||
try {
|
||||
const { stdout, stderr } = await runner.executeOperation(operation, params, projectPath);
|
||||
if (!stdout.trim()) {
|
||||
return createErrorResponse(`${failurePrefix}: ${extractGdError(stderr)}`, emptyStdoutSolutions);
|
||||
}
|
||||
if (options.parseStdoutAsJson) {
|
||||
try {
|
||||
const payload = JSON.parse(stdout.trim());
|
||||
return createStructuredResponse(payload);
|
||||
}
|
||||
catch (parseErr) {
|
||||
return createErrorResponse(`${failurePrefix}: GDScript returned invalid JSON (${getErrorMessage(parseErr)})`, [
|
||||
'This indicates a bug in godot_operations.gd — the operation should emit a JSON payload matching its outputSchema',
|
||||
'Check get_debug_output for the raw stdout and stderr',
|
||||
]);
|
||||
}
|
||||
}
|
||||
return { content: [{ type: 'text', text: stdout }] };
|
||||
}
|
||||
catch (error) {
|
||||
return createErrorResponse(`${failurePrefix}: ${getErrorMessage(error)}`, exceptionSolutions);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Parse a JSON frame returned by the McpBridge. On failure, returns a
|
||||
* structured error response so handlers can short-circuit with one branch.
|
||||
* `context` should describe which bridge command produced the frame.
|
||||
*/
|
||||
export function parseBridgeJson(responseStr, context) {
|
||||
try {
|
||||
return { ok: true, data: JSON.parse(responseStr) };
|
||||
}
|
||||
catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
response: createErrorResponse(`Invalid response from bridge (${context}): ${getErrorMessage(error)}`, [
|
||||
'The bridge returned non-JSON data — check Godot stderr via get_debug_output',
|
||||
'Restart the project with stop_project followed by run_project',
|
||||
]),
|
||||
};
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Attach captured runtime errors as a `warnings` array on a tool response
|
||||
* payload. No-op when there are no runtime errors. Truncates to
|
||||
* `MAX_RUNTIME_ERROR_CONTEXT_LINES` to keep payloads bounded.
|
||||
*/
|
||||
export function attachRuntimeWarnings(target, runtimeErrors) {
|
||||
if (runtimeErrors.length > 0) {
|
||||
target.warnings = runtimeErrors.slice(0, MAX_RUNTIME_ERROR_CONTEXT_LINES);
|
||||
}
|
||||
}
|
||||
//# sourceMappingURL=handler-helpers.js.map
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"handler-helpers.js","sourceRoot":"","sources":["../../src/utils/handler-helpers.ts"],"names":[],"mappings":"AACA,OAAO,EACL,mBAAmB,EACnB,wBAAwB,EACxB,cAAc,EACd,eAAe,GAChB,MAAM,mBAAmB,CAAC;AAE3B,MAAM,CAAC,MAAM,+BAA+B,GAAG,EAAE,CAAC;AAElD;;;;;;;;;;GAUG;AACH,MAAM,CAAC,KAAK,UAAU,cAAc,CAClC,MAAmB,EACnB,SAAiB,EACjB,MAAuB,EACvB,WAAmB,EACnB,aAAqB,EACrB,oBAA8B,EAC9B,qBAA+B,CAAC,qCAAqC,CAAC,EACtE,UAA2C,EAAE;IAE7C,IAAI,CAAC;QACH,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,GAAG,MAAM,MAAM,CAAC,gBAAgB,CAAC,SAAS,EAAE,MAAM,EAAE,WAAW,CAAC,CAAC;QACzF,IAAI,CAAC,MAAM,CAAC,IAAI,EAAE,EAAE,CAAC;YACnB,OAAO,mBAAmB,CACxB,GAAG,aAAa,KAAK,cAAc,CAAC,MAAM,CAAC,EAAE,EAC7C,oBAAoB,CACrB,CAAC;QACJ,CAAC;QACD,IAAI,OAAO,CAAC,iBAAiB,EAAE,CAAC;YAC9B,IAAI,CAAC;gBACH,MAAM,OAAO,GAAG,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,IAAI,EAAE,CAA4B,CAAC;gBACrE,OAAO,wBAAwB,CAAC,OAAO,CAAC,CAAC;YAC3C,CAAC;YAAC,OAAO,QAAQ,EAAE,CAAC;gBAClB,OAAO,mBAAmB,CACxB,GAAG,aAAa,qCAAqC,eAAe,CAAC,QAAQ,CAAC,GAAG,EACjF;oBACE,kHAAkH;oBAClH,sDAAsD;iBACvD,CACF,CAAC;YACJ,CAAC;QACH,CAAC;QACD,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,CAAC,EAAE,CAAC;IACvD,CAAC;IAAC,OAAO,KAAc,EAAE,CAAC;QACxB,OAAO,mBAAmB,CAAC,GAAG,aAAa,KAAK,eAAe,CAAC,KAAK,CAAC,EAAE,EAAE,kBAAkB,CAAC,CAAC;IAChG,CAAC;AACH,CAAC;AAID;;;;GAIG;AACH,MAAM,UAAU,eAAe,CAAc,WAAmB,EAAE,OAAe;IAC/E,IAAI,CAAC;QACH,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,CAAC,KAAK,CAAC,WAAW,CAAM,EAAE,CAAC;IAC1D,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,OAAO;YACL,EAAE,EAAE,KAAK;YACT,QAAQ,EAAE,mBAAmB,CAC3B,iCAAiC,OAAO,MAAM,eAAe,CAAC,KAAK,CAAC,EAAE,EACtE;gBACE,6EAA6E;gBAC7E,+DAA+D;aAChE,CACF;SACF,CAAC;IACJ,CAAC;AACH,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,qBAAqB,CACnC,MAA+B,EAC/B,aAAuB;IAEvB,IAAI,aAAa,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QAC7B,MAAM,CAAC,QAAQ,GAAG,aAAa,CAAC,KAAK,CAAC,CAAC,EAAE,+BAA+B,CAAC,CAAC;IAC5E,CAAC;AACH,CAAC"}
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
export declare const DEBUG_MODE: boolean;
|
||||
export declare function logDebug(message: string): void;
|
||||
export declare function logError(message: string): void;
|
||||
//# sourceMappingURL=logger.d.ts.map
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"logger.d.ts","sourceRoot":"","sources":["../../src/utils/logger.ts"],"names":[],"mappings":"AACA,eAAO,MAAM,UAAU,SAA+B,CAAC;AAEvD,wBAAgB,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAI9C;AAED,wBAAgB,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAE9C"}
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
// Debug mode from environment
|
||||
export const DEBUG_MODE = process.env.DEBUG === 'true';
|
||||
export function logDebug(message) {
|
||||
if (DEBUG_MODE) {
|
||||
console.error(`[DEBUG] ${message}`);
|
||||
}
|
||||
}
|
||||
export function logError(message) {
|
||||
console.error(`[SERVER] ${message}`);
|
||||
}
|
||||
//# sourceMappingURL=logger.js.map
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"logger.js","sourceRoot":"","sources":["../../src/utils/logger.ts"],"names":[],"mappings":"AAAA,8BAA8B;AAC9B,MAAM,CAAC,MAAM,UAAU,GAAG,OAAO,CAAC,GAAG,CAAC,KAAK,KAAK,MAAM,CAAC;AAEvD,MAAM,UAAU,QAAQ,CAAC,OAAe;IACtC,IAAI,UAAU,EAAE,CAAC;QACf,OAAO,CAAC,KAAK,CAAC,WAAW,OAAO,EAAE,CAAC,CAAC;IACtC,CAAC;AACH,CAAC;AAED,MAAM,UAAU,QAAQ,CAAC,OAAe;IACtC,OAAO,CAAC,KAAK,CAAC,YAAY,OAAO,EAAE,CAAC,CAAC;AACvC,CAAC"}
|
||||
Reference in New Issue
Block a user