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:
2026-07-05 22:53:27 -04:00
parent 695e4db5cd
commit 91b878f347
155 changed files with 20335 additions and 972 deletions
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025-2026 Owen Sterling
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+168
View File
@@ -0,0 +1,168 @@
# Godot MCP Runtime
<p align="center">
<a href="https://glama.ai/mcp/servers/@Erodenn/godot-mcp-runtime"><img width="380" height="200" src="https://glama.ai/mcp/servers/@Erodenn/godot-runtime-mcp/badge" alt="godot-runtime-mcp MCP server"></a>
</p>
<p align="center">
<a href="https://modelcontextprotocol.io/introduction"><img src="https://badge.mcpx.dev?type=server" alt="MCP Server"></a>
<a href="https://www.npmjs.com/package/godot-mcp-runtime"><img src="https://img.shields.io/npm/v/godot-mcp-runtime" alt="npm version"></a>
<a href="https://www.npmjs.com/package/godot-mcp-runtime"><img src="https://img.shields.io/npm/dt/godot-mcp-runtime" alt="npm downloads"></a>
<a href="LICENSE"><img src="https://badgen.net/github/license/Erodenn/godot-mcp-runtime" alt="License: MIT"></a>
<a href="https://nodejs.org/"><img src="https://img.shields.io/node/v/godot-mcp-runtime" alt="Node.js"></a>
</p>
A lightweight [MCP](https://modelcontextprotocol.io/) server that pairs comprehensive headless editing with full runtime control over a [Godot](https://godotengine.org/) 4.x project. Scene, node, autoload, and validation ops cover everything short of the most niche corners of the engine; the runtime bridge adds screenshots, input simulation, UI discovery, and live GDScript against the running scene tree.
<p align="center"><img src="docs/assets/demo.gif" alt="Agent driving a Godot game via MCP runtime tools" width="1000"></p>
<h3 align="center">The AI doesn't just write your game, it can check its work.</h3>
<br>
- **Headless editing** — scenes, nodes, scripts, signals, validation, no editor window
- **Runtime control** — screenshots, input simulation, UI discovery, and live GDScript against the running game
- **Zero footprint** — no Godot addon, no project commits, auto-cleanup on shutdown
**No addon required.** Most Godot MCP servers that offer runtime support ship as a Godot addon, something you install into your project, commit to version control, and manage as a dependency. Use npx and there's no install or setup needed.
Think of it as [Playwright MCP](https://github.com/microsoft/playwright-mcp), but for Godot. This does the same thing for games: run the project, take a screenshot, simulate input, read what's on screen, execute a script against the live scene tree. The agent closes the loop on its own changes rather than handing off to you to verify.
> [!NOTE]
> This is not a playtesting replacement. It doesn't catch the subtle feel issues that only a human notices, and it won't tell you if your game is fun. What it does is let an agent confirm that a scene loads, a button responds, a value updated, a script ran without errors. The ability to check work is crucial for AI driven workflows.
## Contents
- [What It Does](#what-it-does)
- [Quick Start](#quick-start)
- [Docs](#docs)
- [Acknowledgments](#acknowledgments)
- [License](#license)
## What It Does
**Built for agents.** Every tool is purpose-built and self-documenting. When something fails, the response tells the agent how to fix it; when something succeeds, it points toward the next step. The result is an AI that stays unstuck and self-corrects without needing you to nudge it along.
**Headless editing.** Create scenes, add nodes, set properties, attach scripts, connect signals, validate GDScript. All the standard operations, no editor window required.
**Runtime bridge.** When `run_project` or `attach_project` is called, the server injects `McpBridge` as an autoload. This opens a localhost-only TCP listener (both auto-select a free port when `bridgePort` is omitted; pass `bridgePort` to pin a specific port) and enables:
- **Screenshots:** Capture the viewport — by default returns a 960x540 preview inline plus the full PNG on disk; use `responseMode: 'full'` for pixel-perfect or `'path_only'` to skip the inline image
- **Input simulation:** Batched sequences of key presses, mouse clicks, mouse motion, UI element clicks by name or path, Godot action events, and timed waits
- **UI discovery:** Walk the live scene tree and collect every visible Control node with its position, type, text content, and disabled state
- **Live script execution:** Compile and run arbitrary GDScript with full SceneTree access while the game is running
**Background mode.** Pass `background: true` to `run_project` and the Godot window moves off-screen (positioned at `(-9999, -9999)`) with physical input blocked: borderless, unfocusable, mouse-passthrough. Programmatic input, screenshots, and all runtime tools work exactly the same. Useful for automated agent-driven testing where the window shouldn't be visible or interactive.
**Manual attach mode.** When something other than MCP launches the game (a CI pipeline, an external debugger, your own shell), call `attach_project` first. It injects the bridge and marks the project active without spawning Godot, so when you launch the game manually, runtime tools work against it. Use `detach_project` when done.
> [!IMPORTANT]
> `get_debug_output` is unavailable in attached mode. stdout and stderr only flow through processes MCP started itself, so when Godot is launched externally there's no captured output to return. Use `run_project` if you need the debug stream.
The bridge cleans itself up automatically when `stop_project` or `detach_project` is called. No leftover autoloads, no modified project files.
## Quick Start
### Prerequisites
- [Node.js](https://nodejs.org/) v20+
- [Godot 4.x](https://godotengine.org/)
That's it. No Godot addon, no project modifications.
### Configure Your MCP Client
Add the following to your MCP client config. Works with Claude Code, Claude Desktop, Cursor, or any MCP-compatible client.
**Zero-install via npx (recommended):**
```json
{
"mcpServers": {
"godot": {
"command": "npx",
"args": ["-y", "godot-mcp-runtime"],
"env": {
"GODOT_PATH": "<path-to-godot-executable>"
}
}
}
}
```
**Or install globally:**
```bash
npm install -g godot-mcp-runtime
```
```json
{
"mcpServers": {
"godot": {
"command": "godot-mcp-runtime",
"env": {
"GODOT_PATH": "<path-to-godot-executable>"
}
}
}
}
```
**Or clone from source:**
```bash
git clone https://github.com/Erodenn/godot-mcp-runtime.git
cd godot-mcp-runtime
npm install
npm run build
```
```json
{
"mcpServers": {
"godot": {
"command": "node",
"args": ["<path-to>/godot-mcp-runtime/dist/index.js"],
"env": {
"GODOT_PATH": "<path-to-godot-executable>"
}
}
}
}
```
> [!TIP]
> **Prefer pnpm?** All three install paths work with pnpm. Substitute `pnpm dlx godot-mcp-runtime` for `npx -y godot-mcp-runtime`, `pnpm add -g godot-mcp-runtime` for the global install, or `pnpm install && pnpm run build` for the source build. pnpm ships stronger defaults against npm supply-chain attacks; see [pnpm's supply chain security guide](https://pnpm.io/supply-chain-security).
If Godot is on your `PATH`, you can omit `GODOT_PATH` entirely. The server will auto-detect it. Set `"DEBUG": "true"` in `env` for verbose logging.
> [!IMPORTANT]
> **Windows path gotchas.** `GODOT_PATH` must point at the Godot executable itself, not its install folder. Backslashes in JSON must be escaped or replaced with forward slashes:
>
> ```json
> "GODOT_PATH": "D:\\Godot\\Godot_v4.4-stable_win64.exe"
> // or equivalently
> "GODOT_PATH": "D:/Godot/Godot_v4.4-stable_win64.exe"
> ```
>
> Setting the variable from a wrapper `.bat` does not propagate to the MCP server — the path must live in the client's `env` block above.
### Verify
Ask your AI assistant to call `get_project_info`. If it returns a Godot version string (e.g., `4.4.stable`), you're connected and working.
## Docs
- [`docs/tools.md`](docs/tools.md) — full tool reference, grouped by category
- [`docs/tool-authoring.md`](docs/tool-authoring.md) — standards for adding or modifying tools
- [`docs/architecture.md`](docs/architecture.md) — source layout, bridge sequence diagram, lifecycle steps, runtime artifact behavior
## Acknowledgments
Built on the foundation laid by [Coding-Solo/godot-mcp](https://github.com/Coding-Solo/godot-mcp) for headless Godot operations.
Developed with [Claude Code](https://claude.ai/code).
## License
[MIT](LICENSE)
+16
View File
@@ -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
+1
View File
@@ -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"}
+69
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"dispatch.js","sourceRoot":"","sources":["../src/dispatch.ts"],"names":[],"mappings":"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"}
+11
View File
@@ -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
+1
View File
@@ -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"}
+114
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
uid://b1jod8iwdknaw
+635
View File
@@ -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
View File
@@ -0,0 +1 @@
uid://3qvnaku3p3c3
+51
View File
@@ -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
View File
@@ -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"}
+191
View File
@@ -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
File diff suppressed because one or more lines are too long
+66
View File
@@ -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
View File
@@ -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"}
+473
View File
@@ -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
File diff suppressed because one or more lines are too long
+65
View File
@@ -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
View File
@@ -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"}
+544
View File
@@ -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
File diff suppressed because one or more lines are too long
+88
View File
@@ -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
View File
@@ -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"}
+948
View File
@@ -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 (01, 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
File diff suppressed because one or more lines are too long
+45
View File
@@ -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
View File
@@ -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"}
+342
View File
@@ -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
File diff suppressed because one or more lines are too long
+15
View File
@@ -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
View File
@@ -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"}
+375
View File
@@ -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
File diff suppressed because one or more lines are too long
+38
View File
@@ -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
View File
@@ -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"}
+124
View File
@@ -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
View File
@@ -0,0 +1 @@
{"version":3,"file":"autoload-ini.js","sourceRoot":"","sources":["../../src/utils/autoload-ini.ts"],"names":[],"mappings":"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"}
+46
View File
@@ -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
View File
@@ -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"}
+186
View File
@@ -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
File diff suppressed because one or more lines are too long
+37
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -0,0 +1 @@
{"version":3,"file":"bridge-protocol.js","sourceRoot":"","sources":["../../src/utils/bridge-protocol.ts"],"names":[],"mappings":"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"}
+248
View File
@@ -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
File diff suppressed because one or more lines are too long
+1119
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+36
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"}
+4
View File
@@ -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
+1
View File
@@ -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"}
+11
View File
@@ -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
+1
View File
@@ -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"}
+72
View File
@@ -0,0 +1,72 @@
{
"name": "godot-mcp-runtime",
"version": "3.1.2",
"description": "A lightweight, zero-footprint TypeScript MCP server that lets AI assistants interact with the Godot 4.x game engine: not just editing files, but playing the game.",
"main": "dist/index.js",
"type": "module",
"bin": {
"godot-mcp-runtime": "dist/index.js"
},
"scripts": {
"build": "tsc && node scripts/copy-scripts.js",
"start": "node dist/index.js",
"dev": "npm run build && node dist/index.js",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"typecheck": "tsc --noEmit",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write .",
"format:check": "prettier --check .",
"verify": "bash scripts/verify.sh",
"safe-install": "node scripts/safe-install.js",
"snapshot-cross-platform-deps": "node scripts/snapshot-cross-platform-deps.js",
"install-hooks": "node scripts/install-hooks.js"
},
"keywords": [
"mcp",
"godot",
"game-development",
"model-context-protocol",
"ai",
"game-engine",
"godot4"
],
"author": "Owen Sterling",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/Erodenn/godot-mcp-runtime.git"
},
"homepage": "https://github.com/Erodenn/godot-mcp-runtime",
"bugs": {
"url": "https://github.com/Erodenn/godot-mcp-runtime/issues"
},
"mcpName": "io.github.Erodenn/godot-mcp-runtime",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.25.2"
},
"devDependencies": {
"@eslint-community/eslint-plugin-eslint-comments": "^4.7.1",
"@types/node": "^25.0.9",
"@typescript-eslint/eslint-plugin": "^8.59.1",
"@typescript-eslint/parser": "^8.59.1",
"@vitest/coverage-v8": "^4.1.5",
"ajv": "^8.20.0",
"eslint": "^9.39.4",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-import": "^2.32.0",
"prettier": "^3.8.3",
"typescript": "^5.9.3",
"vitest": "^4.1.5"
},
"files": [
"dist/",
"README.md",
"LICENSE"
],
"engines": {
"node": ">=20.0.0"
}
}