diff --git a/.codex/config.toml b/.codex/config.toml new file mode 100644 index 0000000..f2cd8dd --- /dev/null +++ b/.codex/config.toml @@ -0,0 +1,6 @@ +[mcp_servers.godot-mcp-toolkit] +command = "cmd" +args = ["/c", "npx", "-y", "@npgamedev/godot-mcp-server"] +cwd = "C:/Users/Tomek/Documents/Godot/Randoloot" +startup_timeout_sec = 60 +env = { GODOT_MCP_CONFIG_VERSION = "1", GODOT_MCP_PROJECT_PATH = "C:/Users/Tomek/Documents/Godot/Randoloot" } diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..1bcc7da --- /dev/null +++ b/.mcp.json @@ -0,0 +1,16 @@ +{ + "mcpServers": { + "godot-mcp-toolkit": { + "args": [ + "/c", + "npx", + "-y", + "@npgamedev/godot-mcp-server" + ], + "command": "cmd", + "env": { + "GODOT_MCP_CONFIG_VERSION": "1" + } + } + } +} diff --git a/addons/godot_mcp_toolkit/.mcp.json.template b/addons/godot_mcp_toolkit/.mcp.json.template new file mode 100644 index 0000000..3d401ba --- /dev/null +++ b/addons/godot_mcp_toolkit/.mcp.json.template @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "godot-mcp-toolkit": { + "command": "npx", + "args": ["-y", "@npgamedev/godot-mcp-server"], + "env": { + "GODOT_MCP_CONFIG_VERSION": "1" + } + } + } +} diff --git a/addons/godot_mcp_toolkit/ATTRIBUTIONS.md b/addons/godot_mcp_toolkit/ATTRIBUTIONS.md new file mode 100644 index 0000000..d0e0d33 --- /dev/null +++ b/addons/godot_mcp_toolkit/ATTRIBUTIONS.md @@ -0,0 +1,151 @@ +# Attributions (godot-mcp-toolkit) + +This addon's GDScript source was independently written. No code from any +reference repository has been copied verbatim or near-verbatim into this addon. +The entries below cover the original artwork made for this project and the +architectural patterns we studied while designing the plugin. + +If a future version imports or adapts code from any of the sources below, a +"Copied into: …" line with the file path(s) will be appended to keep this file +accurate. + +--- + +## Artwork + +The project logo and hero banner were created by **Jessica Mariana Aisen** for the +Godot MCP Toolkit. + +Commissioned for this project; the copyright is held by NPGameDev. + +The MIT licence covers the source code. It does not cover the project logo, +icons, banner or name, which are the project's brand identity and are not +licensed for reuse. + +--- + +## Coding-Solo/godot-mcp + +Source: +License: MIT + +Copyright (c) 2025 Solomon Elias + +Contributed (architecture reference only — no code copied): bundled-GDScript +handler pattern (the editor plugin loads and runs a registry of command +handlers), cross-platform Godot auto-detection approach, debug output capture +strategy. + +--- + +## tugcantopaloglu/godot-mcp + +Source: +License: MIT + +Copyright (c) 2025 Tugcan Topaloglu +Copyright (c) 2025 Solomon Elias + +Contributed (architecture reference only — no code copied): code-execution +pattern (arbitrary GDScript execution with return values and `await` support), +signal management system concepts (connect/disconnect/emit/await with timeout), +generic node property inspection via `get_property_list()`, reentrancy-guard +pattern for concurrent command prevention. + +--- + +## salvo10f/godotiq + +Source: +License: MIT + +Copyright (c) 2026 GodotIQ + +Contributed (architecture reference only — no code copied): three-tier scene +parser architecture (raw → resolved → indexed), spatial intelligence tool +concepts (`scene_map`, placement, spatial audit), token-optimisation approach +(brief / normal / full detail levels), `EngineDebugger` IPC pattern for +runtime access, **the UndoRedo + `add_undo_reference` pattern for editor-safe +node deletion** (our scene-delete handler standardises on the same pattern — +the code itself was independently written). + +--- + +## youichi-uda/godot-mcp-pro (GDScript plugin only) + +Source: — `addons/godot_mcp/` +only. +License: MIT (plugin component); the TypeScript server component is separately +licensed and is NOT referenced or reproduced here. + +Copyright (c) 2026 Youichi Uda (y1uda) + +Contributed (architecture reference only — no code copied): WebSocket bridge +architecture (Godot editor plugin ↔ external Node.js process), JSON-RPC 2.0 +over WebSocket protocol design, UndoRedo integration approach (see +salvo10f/godotiq entry — both projects converge on the same pattern), port +`6505` as the standard MCP port for this style of plugin, **returning screenshot +bytes inline via base64 + mime_type** (our screenshot handler standardises on +the same shape — independently written). + +--- + +## ee0pdt/Godot-MCP + +Source: +License: MIT + +Copyright (c) 2025 (author unnamed in LICENSE) + +Contributed (architecture reference only — no code copied): structural reference +for the two-layer plugin + external-server architecture. + +--- + +## tomyud1/godot-mcp + +Source: +License: MIT + +Copyright (c) 2025-2026 Tomer Yud + +Contributed (architecture reference only — no code copied): reference +implementation for MCP server + Godot plugin integration. + +--- + +## rayxuln/hastur-operation-plugin + +Source: +License: MIT + +Copyright (c) 2026 Raiix + +Contributed (architecture reference only — no code copied): GDScript snippet +execution pattern (wrap user code in `@tool extends RefCounted`, call +`execute(context)`), broker-relay architecture reference. + +--- + +## AndreaTerenz/WebSocket + +Source: +License: MIT + +Copyright (c) 2023 Andrea Terenziani + +Contributed (architecture reference only — no code copied): Godot 4 +`WebSocketPeer` wrapper patterns. + +--- + +## Notes + +MIT only requires preserving notices for code that is directly copied or +substantially reproduced. No code from the repositories listed above was +copied into this addon — those entries document the architectural study we +credit by courtesy. + +The companion npm package (`@npgamedev/godot-mcp-server`) carries its own +`ATTRIBUTIONS.md` with the subset of references relevant to the bridge / +Node.js side. diff --git a/addons/godot_mcp_toolkit/CompanionSkills/.gdignore b/addons/godot_mcp_toolkit/CompanionSkills/.gdignore new file mode 100644 index 0000000..9e08972 --- /dev/null +++ b/addons/godot_mcp_toolkit/CompanionSkills/.gdignore @@ -0,0 +1,8 @@ +# CompanionSkills holds installable companion skills and their reference files +# (documentation), not Godot project code. This .gdignore keeps the editor from +# scanning the folder, so a reference such as +# mcp-extension-creator/references/example-extension.gd — a class_name'd +# MCPToolkitExtension shown as a copy-paste template — is never registered as a +# global class or auto-discovered as a live extension in this project (or in an +# end user's project once the addon is installed). Copy the template into your +# own extension location to activate it. diff --git a/addons/godot_mcp_toolkit/CompanionSkills/godot-mcp-toolkit/SKILL.md b/addons/godot_mcp_toolkit/CompanionSkills/godot-mcp-toolkit/SKILL.md new file mode 100644 index 0000000..ae17357 --- /dev/null +++ b/addons/godot_mcp_toolkit/CompanionSkills/godot-mcp-toolkit/SKILL.md @@ -0,0 +1,494 @@ +--- +name: godot-mcp-toolkit +description: > + Best practices for building Godot 4.x games through the Godot MCP Toolkit. + Covers tool selection, workflow patterns, error recovery, and token efficiency + for MCP-connected agents. +when_to_use: > + When the conversation involves building, editing, or testing a Godot project + through MCP tools such as game_start, scene_create, node_set_property, + discover_tools, script_write, or any godot-mcp-toolkit tool. +--- + +You are assisting with a Godot 4.x project through the Godot MCP Toolkit. +Follow these patterns to work efficiently, avoid common pitfalls, and +minimise wasted tool calls. + +--- + +## 1. Quick reference + +**The tool surface:** a lean **startup surface** is visible the moment you +connect (the eager tools plus `discover_tools` / `extensions_refresh`); it +expands to the **full surface** on demand — call `discover_tools` to activate the +group a task needs. **Read-only mode** (`GODOT_MCP_READ_ONLY=1` in your +`.mcp.json` env block, or the dock's read-only toggle) hides every mutating tool +on top of whichever surface is active — safe for exploration and auditing. + +**On-demand groups** — activate with `discover_tools`: +- `discover_tools({request: ["runtime_advanced", "tilemap"]})` — pass one or + more group names or keywords in a single call +- Browse only: add `activate: false` to list a group's tools without loading +- Deactivate: `discover_tools({reset: true})` (all) or `reset: ["tilemap"]` +- **Schema enrichment:** only if you activated a group but the new tools aren't + showing in your tool list, call `discover_tools` again with + `include_schemas: true` to get their parameter schemas inline +- **Short keywords:** fuzzy search matches short terms (`"3d"`, `"2d"`, `"ui"`, + `"ai"`) only against explicit keywords, not by substring. If a short keyword + returns nothing, use the exact group name + +**High-risk tools** — some tools carry a `destructiveHint: true` MCP annotation +so agents see the risk signal. They are always available (no opt-in needed); you +decide whether to use them based on context. + +**Finding tools** (Claude Code tip): use keyword search (e.g. `godot scene`) +rather than `select:` syntax, or the full prefixed name +`select:mcp__godot-mcp-toolkit__tool_name`. Group related lookups into one query. + +--- + +## 2. Tool selection + +### By task + +| Task | Tools | +|------|-------| +| Create a scene | `scene_create` -> `scene_open` -> `scene_create_node` -> `node_set_property` | +| Edit an existing script | `script_edit` (exact `old_string` -> `new_string`) -> `script_check` | +| Write a new script | `script_write` -> `node_set_script` | +| Test a game | `game_start` -> `discover_tools({request: "runtime"})` -> runtime tools | +| Explore a project | `scene_get_tree` -> `script_read` -> `asset_list` | +| Diagnose errors | `editor_get_console` -> `script_check` -> `lsp_project_diagnostics` (`lsp_code_analysis` group) -> `debugger_get_log` | +| Navigate/inspect code | `lsp_definition` / `lsp_references` / `lsp_symbols` (`lsp_code_navigation` / `lsp_code_analysis` groups) | +| Inspect a class | `classdb_get_info` -> `classdb_search` (`classdb` group) | + +### Decision rules + +- **Reading one property?** Use `node_get_property` — cheaper than any tree walk. +- **Surveying a tree?** `scene_get_tree` defaults to `max_depth: 2` (use `-1` for + the full tree, so you don't act on a truncated view). Prefer a filtered + `scene_query` (`offset`/`limit`, default 50, clamped to 200) over + `scene_get_tree(include_properties: true)` for surveys — paged reads carry a + `returned`/`has_more` envelope; re-query from `offset: 0` if the tree changed. +- **Need a computed value or method call at runtime?** Use `execute_code`. For a + single property read, `node_get_property` is cheaper. +- **Editor vs runtime diagnostics?** `editor_get_console` for compilation errors + and editor warnings; `debugger_get_log` for runtime crashes and game state. +- **Control layout?** `control_set_layout` sets an anchor preset plus optional + margins in one call and returns the final rect. +- **Exploring an unfamiliar node class?** `classdb_get_info` (`classdb` group) + returns every property, method, and signal with types. Follow with + `node_set_property` to set what you find — covers any domain without + memorising the API. +- **Finding a class by capability?** `classdb_search({pattern: "collision", + instantiable_only: true})`. Narrow with `base_class: "Node2D"`. + +--- + +## 3. Workflow patterns + +### Scene creation -> test -> verify + +1. `scene_create` — saves a new `.tscn` to disk; it does **not** open it +2. `scene_open` — make it the active scene (required before adding nodes) +3. `scene_create_node` for child nodes (batch siblings in one turn; inline + `properties` supported — on partial failure the node is kept, check + `properties_failed`) +4. `script_write` (new file) / `script_edit` (existing) → `node_set_script` +5. `editor_save_scene` to persist the mutations +6. `game_start` — returns `runtime_ready: true` when the runtime connects +7. `discover_tools({request: "runtime"})` → `runtime_get_node_state` / + `input_simulate` to verify + +**Dependency-aware build order.** Create resources before scripts that reference +them — "don't reference something that doesn't exist yet." Adapt to the project's +dependency graph: utility scripts (no deps) → scenes (empty node trees) → scripts +referencing scenes (use `load()`, runtime-evaluated) → autoloads via +`autoload_manage` (not `project_set_setting`) → scripts referencing autoloads (by +`class_name`). + +### Batch and parallelise scene construction + +Two optimisations that compose: **batch calls across siblings** — all +`scene_create_node` calls for one parent in a single turn (only serialise +parent-child pairs); **batch mode within a call** — `node_set_property` with +`batch` sets many properties on one or more nodes atomically. Independent **read** +calls can also go out together in one turn (reads bypass the mutation queue; only +order-dependent mutations need sequencing). Combined, scene setup drops from +30-40 sequential turns to 3-4 batched turns. + +```json +node_set_property({ + batch: [ + {node_path: "Player", property: "position", value: {type: "Vector2", x: 100, y: 200}}, + {node_path: "Enemy", property: "visible", value: false} + ] +}) +``` +When `batch` is present, top-level `property`/`value` are ignored. + +**Autoload setup.** Use `autoload_manage` to register autoloads — not +`project_set_setting` (which rejects `autoload/*` keys and redirects). Register as +early as possible so other scripts can reference them by `class_name`. After +registering, call `editor_refresh` (`editor_advanced` group) to flush the editor +cache: console errors that reference autoload singletons (e.g. "identifier not +found") are usually stale cache — don't re-register or rewrite scripts until +you've refreshed and re-checked. + +### Playtest verification loop + +After `game_start`: verify key nodes exist with the right properties → +`input_simulate` to send inputs (`delay_after_ms` 100-300ms) → verify state +changed → repeat per scenario. **Warning:** `delay_after_ms` > 500ms is dangerous +— the game world is live during delays, so enemies move and timers tick. + +**Verifying game state (cheapest to most expensive):** + +1. `debugger_get_log` — read `print()` output (available from startup; works + during gameplay AND after a crash; `limit` / `text_filter`). Add strategic + prints for key state (score, health, wave). Cheapest runtime check. +2. `runtime_get_node_state` (`runtime_advanced` group) — primarily `@export` + vars and inspector-visible fields; put `@export` on key state vars. +3. `runtime_get_script_vars` — all script variables, not just exports (available + from startup; `visibility` filter: `public`/`private`/`all`). +4. `execute_code` — arbitrary expressions on any node (available from startup; + expression-only). Most flexible, larger responses than 1-3. +5. `runtime_screenshot` — visual check. **Last resort**, 5-10x the tokens of a + text tool. Use `image_detail: "low"`/`"mid"` to save budget; on + `RUNTIME_WINDOW_MINIMIZED`, retry with `force_foreground_game: true`. + +Prefer 1-3; use 4 for computed values; use 5 only for visual layout, alignment, +or rendering questions text tools can't answer. + +### Fast-forward testing with `runtime_set_property` + +Instead of playing through the whole game, set late-game state directly. +`runtime_set_property` (`runtime_advanced` group) supports compound paths +(`position:x`). Example — test victory after wave 5: +``` +runtime_set_property(node_path: "/root/GameManager", property: "current_wave", value: 5) +runtime_set_property(node_path: "/root/GameManager", property: "enemies_remaining", value: 0) +``` +Then verify the victory screen triggers — skipping minutes of play and dozens of +`input_simulate` calls. + +**Scene instantiation.** `scene_instantiate` (`scene_advanced` group) auto-renames +on name collision, so multiple instances of one scene work without manual +renaming. Its `instances: [{name?, position?, rotation?, scale?, properties?}]` +batch spawns N copies with transforms in one call. + +**Tilemap bulk fills.** Use the `regions` parameter on `tilemap_set_cells` for +large rectangular fills instead of listing cells one at a time (can be combined +with `cells`). Read cells back with `tilemap_read_cells` (both in the `tilemap` +group). + +### Script writing patterns + +- Always add `class_name` — enables script-to-script references without + `load()`/`preload()`. +- `@export` for properties visible in the inspector and via + `runtime_get_node_state`. +- Declare signals with typed parameters: `signal health_changed(new_hp: int)`. +- `@onready var label: Label = %ScoreLabel` with unique-name nodes. +- **Prefer `script_edit` for changes to an existing script** — a surgical + `old_string` -> `new_string` swap that keeps undo history and returns inline + diagnostics. Use `script_write` for new files. +- **Use `script_write` / `script_edit` for EVERY script file** — including scratch + drafts, experiments, and throwaway managers, never a native file-write tool. The + MCP tools compile-check the code and register it with the running editor; a raw + filesystem write does neither, so it is never the better choice in a live project. + +**Placeholder art.** Use Godot primitives (ColorRect, Polygon2D, simple shapes) +for placeholder visuals. For quick placeholder assets, `texture_generate` / +`sound_generate` (`placeholders` group) exist — no external image tools needed. + +### Type wrappers for `node_set_property` + +Engine types need a `{type: ...}` wrapper; plain numbers, strings, and booleans +don't. The essentials — bind a resource, position, or colour: + +- Resource: `{type: "Resource", path: "res://texture.png"}` (textures, audio, materials) +- Vector2: `{type: "Vector2", x, y}` +- Color: `{type: "Color", r, g, b, a}` (`a` defaults to 1.0) + +Unknown `type` tags are rejected with the supported list. Full table — packed +arrays, `Transform2D`/`Transform3D`, `NewResource`, `NodePath`, `LayerMask`, +compound paths — in `references/type-wrappers.md`. + +### Signal workflow + +- `signal_manage` — **editor-time** connect/disconnect (CONNECT_PERSIST — saved + in `.tscn`, survives save/load; idempotent). Validates the signal exists on + the source and the method on the target. +- `signal_list` — inspect signals and their connections (`include_connections`). +- `signal_emit` — fire a signal **now**: `channel: "editor"` (default, edited + scene) or `channel: "runtime"` (the running game — needs the `signals` group + activated and a running game). + +**Typical pattern:** connect during setup with `signal_manage` -> `game_start` +-> `signal_emit(channel: "runtime")` to trigger during testing. Cross-scene +connections (source and target in different scenes) cannot persist — +`signal_manage` returns a hint steering you to `_ready()` code instead. + +**Editor state management — refresh after external writes, save after +mutations.** `editor_refresh` (`editor_advanced` group) is for files created +**outside** MCP tools (native Write/Bash) and after autoload registration +(stale-cache errors); targeted `file_paths` is O(1) per file. `editor_save_scene` +is **not automatic** — save after mutation batches before switching scenes or +starting a playtest; saving flushes pending changes (no refresh-before-save +needed). + +**`input_simulate`.** Send a single event or an `events` array — **prefer one +call with multiple events**. Keep `delay_after_ms` to 100-300ms; > 500ms is +dangerous (the live game world advances during the wait). `click_node` +(`node_path`, no coordinate guessing) and `send_text` (types into the focused / +`node_path` field, firing real signals) exist. Coordinate modes: `position` +(viewport/UI) vs `world_position` (game-world, camera-aware). Full event-type +table in `references/input-events.md`. + +--- + +## 4. Tool availability + +A lean startup surface is small **on purpose** — large always-loaded tool lists +degrade tool-selection accuracy in LLM contexts. Specialised tools live in +on-demand groups you activate with `discover_tools` to reach the full surface. + +**Common groups — examples, not the full list:** + +| Group | Contains | +|-------|----------| +| `runtime_advanced` | `runtime_get_node_state`, `runtime_set_property`, `animation_player_control` | +| `signals` | `signal_emit` (`signal_manage` / `signal_list` are on the startup surface) | +| `animation_authoring` | `animation_keyframe`, `animation_get_keys`, `animationtree_edit`, ... | +| `input_map` | `input_map_action`, `input_map_event` | +| `tilemap` | `tilemap_set_cells`, `tilemap_read_cells` | +| `audio` | `audiobus_edit`, `audiobus_list` | +| `3d_tools` | `3d_create_primitive`, `3d_create_camera`, `3d_create_light`, ... | +| `navigation` | `navigation_edit` | +| `lsp_code_analysis` | `lsp_diagnostics`, `lsp_project_diagnostics`, `lsp_hover`, `lsp_symbols` | +| `lsp_code_navigation` | `lsp_completion`, `lsp_definition`, `lsp_references` | +| `debugger` | `debug_state`, `debug_set_breakpoint`, `debug_continue`, ... | +| `editor_advanced` | `editor_refresh`, `editor_screenshot`, `editor_wait_for_idle` | +| `classdb` | `classdb_get_info`, `classdb_search` | +| `cleanup` | `file_delete`, `folder_delete`, `resource_delete`, `scene_close`, ... | + +Call `discover_tools()` with no arguments to see the full catalogue. + +**Version gating:** on older Godot versions, version-gated tools are hidden from +the tool list; a direct call fails with `UNSUPPORTED`, and the hint names the +required version. + +**Extensions:** projects can register their own MCP tools. They appear in +`discover_tools` alongside the built-ins; `extensions_refresh` rescans after the +extension files change. + +**Read-only mode.** Set `GODOT_MCP_READ_ONLY=1` in `.mcp.json` env (or use the +dock's read-only toggle, which writes the env var for you). Only tools with +`readOnlyHint: true` are visible — safe for exploration and auditing; the same +annotation-driven filter applies to built-in and extension tools. To exit, turn +it off and reconnect the client — the tool list is decided at connect time. + +**Context management:** if on-demand groups accumulate and degrade tool +selection, reset them — `discover_tools({reset: true})` (all) or +`discover_tools({reset: ["tilemap", "audio"]})` (selective). + +--- + +## 5. Headless mode + +**Launch:** `godot --headless --editor --path ` + +Build and validate normally: `script_check`, `lsp_project_diagnostics`, +`editor_get_console`, and scene/node inspection all work. Run `editor_refresh` +after external file writes (the filesystem scan is async headless). + +**Playtesting is unavailable headless** — there is no display server for the +game process, so `game_start` fails with `HEADLESS_UNSUPPORTED`, and +`editor_screenshot` fails the same way. Verify via state queries and console +output instead. + +--- + +## 6. Key constraints + +**Never edit .tscn files directly.** Always use the MCP scene tools +(`scene_create`, `scene_create_node`, `node_set_property`). Direct `.tscn` edits +bypass the editor's scene tree and cause silent data loss. + +**Root node path.** The root node is always path `"."` — not the node name. +Children are relative. + +**Runtime state visibility.** `runtime_get_node_state` shows primarily `@export` +vars and inspector-visible fields. Use `execute_code` for internal state. + +**Node method invocation.** `node_call_method` is editor-side only. For methods +on the running game use `execute_code` (its default channel is the running game): +`get_node('/root/Main/Player').call('take_damage', 25)`. + +### Stale live instance after a script edit (Godot < 4.4) + +On Godot **< 4.4** (4.2, 4.3), editing a `.gd` already attached to a **live** +node does **not** reach that running instance: a newly-added method fails with +`INVALID_METHOD`, and a changed method **body** runs the old code silently. +`editor_refresh`, re-`node_set_script`, and a fresh node all keep the stale code +— **relaunch the editor** (or disable+re-enable the plugin) before calling the +changed members (`script_write` and `node_call_method` flag this in their hints). +Best practice: write the full script **before** attaching it. On 4.4+ scripts +hot-reload, so this doesn't apply. + +**Env var lifecycle.** `GODOT_MCP_*` env vars (e.g. `GODOT_MCP_READ_ONLY`, +`GODOT_MCP_EDITOR_PORT`) are read when the MCP server process starts. After +editing `.mcp.json`, reconnect the MCP client — the tool list is decided at +connect time, and a reconnect respawns the server with the fresh env. + +**GDScript pattern — prefer load() over preload().** Use `class_name` on every +script for script-to-script references (no preload/load needed). Use `load()` for +PackedScene instantiation and resource references — it evaluates at runtime and +tolerates creation order. Never use `preload()` in generated code unless the +target already exists and there are no circular dependencies. + +**GDScript pattern — use %UniqueName for node references.** Mark +frequently-referenced nodes with `unique_name: true` (via `scene_create_node`, or +by setting `unique_name_in_owner` with `node_set_property`). Reference them as +`@onready var label: Label = %ScoreLabel` — this survives reparenting. Use +`$ChildName` only for structural direct children. + +**Console log sources.** `editor_get_console` buffer mode captures all output on +Godot 4.5+ (Logger API); it falls back to file-tail on 4.2-4.4 with ~200ms +latency. Use `source: "buffer"` (default) for real-time capture. + +**Node groups.** Use `node_groups` to add/remove/list groups — not +`node_set_property` (groups are not a node property). Batch mode supports +multiple nodes: +`node_groups(action: "add", entries: [{node_path: ".", group: "enemies"}, ...])`. + +**File path conventions.** Project files use `res://`; `user://` data goes +through the save tools (`save_read` / `save_write` / `save_delete` / `save_list`, +`user_data` group) and screenshot save paths (`user://screenshots/`). Absolute OS +paths and `..` traversal are rejected by the file guard. `scene_create` and +`script_write` auto-create parent directories; other tools may not — use +`folder_create` for non-standard paths first. + +--- + +## 7. Error recovery + +**Debug-first — when runtime behaviour is unexpected, always diagnose before +retrying:** + +1. `debugger_get_log` — check what actually happened +2. Check `delay_after_ms` in `input_simulate` (>500ms lets the world advance) +3. Check game state via `execute_code` (still playing? player alive?) +4. THEN retry with adjusted parameters + +This avoids misdiagnosis loops that waste thousands of tokens retrying when the +root cause is elsewhere (e.g. input delays letting enemies kill the player before +events process). + +### Common error codes + +| Code | Cause | Recovery | +|------|-------|----------| +| `GAME_NOT_RUNNING` | No game started, or it crashed | `game_start`, then `editor_get_console` if it fails again | +| `NOT_FOUND` | Node path, file, or `script_edit` `old_string` not found | Root is `"."`, children relative; check `scene_get_tree` | +| `NOT_UNIQUE` | `script_edit` `old_string` matched more than once | Widen the span or pass `replace_all` | +| `PARENT_NOT_FOUND` | Parent dir doesn't exist | `scene_create` / `script_write` auto-create dirs; others may not | +| `ALREADY_PLAYING` | Game already running | `game_stop` first, or use `if_running: "return"` | +| `COMPILATION_FAILED` | Script errors | `editor_refresh` then `editor_get_console` for details | +| `UNSUPPORTED` | Tool/operation needs a newer Godot | The hint names the required version | +| `LOG_UNAVAILABLE` | Log couldn't be read | On 4.5+ `source: "buffer"` works without a file; on 4.2-4.4 enable file logging (ProjectSettings -> Debug) + restart | +| `LOG_BUSY` | Log read open failed — an external process may hold it | Retry shortly; on 4.5+ use `source: "buffer"` (in-memory) | + +For deeper runtime diagnosis when logs aren't enough, activate the `debugger` +group (`debug_state`, breakpoints, `debug_continue`). + +--- + +## 8. Checkpoint pattern + +For complex builds, commit progress at milestones. A standard order: project +structure + main scene → game logic scripts → input handling → win/lose +conditions → visual feedback → regular verification (`game_start` + runtime +checks) → headless verification (if applicable). + +If a session ends mid-task, record the current step and state so the next session +can continue without re-reading the entire project. + +--- + +## 9. Token efficiency + +MCP tool calls are expensive — each round trip costs tokens for the request, the +response, and the context it occupies: + +- **Batch properties** instead of one-at-a-time: one `node_set_property` with + `batch` replaces 5-10 individual calls. +- **Activate groups in one call.** Pass an array to `discover_tools` — every + activation makes the client re-process the full tool list, so batch them. +- **Use `scene_get_tree` sparingly.** `include_properties: true` returns + hundreds of lines even on a small scene; use `node_get_property` for targeted + reads. Paged reads (`scene_query`, `editor_get_console`, `debugger_get_log`) + carry a `returned`/`has_more` envelope. +- **Read the console incrementally.** `editor_get_console` supports `since_id` + (page "what's new"), `level_filter`, and `limit`; `debugger_get_log` supports + `limit` and `text_filter`. +- **Keep `node_get_property_list` on `mask="common"`** (default; the 8-12 + most-edited properties) or use `node_get_property` — `mask="all"` is large. +- **Never screenshot for debugging logic.** `debugger_get_log` + + `runtime_get_node_state` cost a fraction of a screenshot. Budget: + `image_detail: "low"` for layout sanity, `"mid"` for most UI checks, `"full"` + only to read small text; disk modes always persist full-res. Only use + `runtime_screenshot` for visual layout, alignment, or rendering issues. +- **Partial script reads.** If you wrote the script, read only the function you + need (`start_line`/`end_line`). +- **Reset unused tool groups.** Accumulated on-demand groups degrade tool + selection — `discover_tools({reset: true})` clears them. +- **Diagnose before retrying.** One `debugger_get_log` that reveals the root + cause beats blindly retrying `input_simulate` five times. +- **Use `classdb_get_info` with `sections`.** All sections on a complex class + returns thousands of lines — ask for `["properties"]` or `["signals"]` only. +- **Use specific keys for project settings.** `project_get_settings(prefix: + "display")` returns hundreds of settings (including defaults); use a single + `key` for one value. For a broad survey, `Read(project.godot)` is far cheaper — + it holds only non-default settings. If the spec states the value, skip the read + and set it directly. +- **Follow the `hint` field** on both errors and successes instead of + re-querying or blindly retrying — it names the recovery step or the next tool. + +--- + +## 10. Parallel sessions + +Multiple sessions on one editor are safe — a mutation queue and a scene lease +serialize conflicting work automatically; under contention responses get +**slower, not broken** (a lease steal takes ~8s). If a response mentions another +session editing a scene, do tab-independent work (scripts, files, project +settings, autoloads, console reads) first. Full guide: +`references/parallel-sessions.md`. + +--- + +## Gotcha quick-reference + +These are the most common sources of wasted tool calls and retries: + +| Tool | Gotcha | +|------|--------| +| `input_simulate` | `delay_after_ms` > 500ms is dangerous — game world advances | +| `scene_create_node` | `parent_path` is the parent to create under; `node_name` names the new node | +| `node_set_property` | `batch` mode: top-level `property`/`value` ignored when `batch` present | +| `node_set_property` | Typed values need `{type: "Vector2", x: ...}` wrappers — plain `{x: 1, y: 2}` won't coerce | +| `script_edit` | `old_string` must match byte-for-byte (whitespace included); `NOT_UNIQUE` -> widen the span or `replace_all` | +| `signal_manage` vs `signal_emit` | `signal_manage` = persistent editor-time connect/disconnect; `signal_emit` = fire now (`channel: "editor"` default \| `"runtime"`) | +| `scene_create` vs `scene_create_node` | `scene_create` = new `.tscn` file (open it after); `scene_create_node` = add a node to the open scene | +| `autoload_manage` vs `project_set_setting` | Always use `autoload_manage` for autoloads; `project_set_setting` rejects them | +| `autoload_manage` + console errors | After registering, `editor_refresh` before trusting console errors — stale cache shows false "identifier not found" | +| `execute_code` | Annotated `destructiveHint: true`; expression-only; responses larger than a property read | +| `editor_refresh` | Needed after external writes; especially headless | +| `scene_delete` / `scene_close` | Console shows `_set_main_scene_state: Cannot convert argument 2 from Object to Object` when closing non-active tabs — **benign Godot engine noise** from the deferred queue, not a bug. Tabs close correctly. Don't try to fix or investigate it. | +| `game_start` | `scene_path` accepts `"main"`, `"current"`, or a `res://` path | +| `node_groups` | Only way to manage groups — `node_set_property` cannot set them | +| `classdb_get_info` | Use the `sections` param to limit output — a full dump includes hundreds of inherited properties | +| `editor_save_scene` | Not automatic — call after mutation batches before playtest or scene switch | +| `project_get_settings` | Broad `prefix` returns hundreds of lines — use a specific `key` or skip the read if the spec states the value | diff --git a/addons/godot_mcp_toolkit/CompanionSkills/godot-mcp-toolkit/references/input-events.md b/addons/godot_mcp_toolkit/CompanionSkills/godot-mcp-toolkit/references/input-events.md new file mode 100644 index 0000000..666f806 --- /dev/null +++ b/addons/godot_mcp_toolkit/CompanionSkills/godot-mcp-toolkit/references/input-events.md @@ -0,0 +1,52 @@ +# `input_simulate` event reference + +`input_simulate` injects input into the **running game** (requires `game_start`). + +## Event shape + +Each event is `{event_type, event_data?, delay_before_ms?, delay_after_ms?}`. +Pass a single event object, or an `events` array for a sequence — **prefer one +call with multiple events** over separate calls. `summary: true` (the default) +returns compact per-event output. + +## Event types + +| `event_type` | Key `event_data` fields | Notes | +|--------------|-------------------------|-------| +| `key` | `keycode`, `pressed` | Godot key constants (e.g. `KEY_SPACE`) | +| `mouse_button` | `button_index`, `position`, `pressed` | `button_index`: 1 = left, 2 = right | +| `mouse_motion` | `position`, `relative` | For drag / hover | +| `action` | `action`, `pressed` | Matches an Input Map action name | +| `click` | `position` (or `world_position`) | Composite: press + 50 ms + release at the position via `push_input` (GUI-safe; no OS mouse warp or window focus) | +| `click_node` | `node_path` | Calls `grab_focus` + emits `pressed` on `BaseButton`s — no coordinate guessing | +| `send_text` | `text` (required), `node_path?`, `submit?` | Types a string by synthesizing per-character key events, firing the real `text_changed` / `text_submitted` signals that setting `.text` skips | + +## Coordinate modes (mouse events) + +- `position: {x, y}` — raw viewport / screen coordinates (default). Use for UI + elements (buttons, menus). +- `world_position: {x, y}` — game-world coordinates, auto-translated via the + canvas transform (accounts for camera offset and zoom). Use for clicking a + specific in-game location. + +## `send_text` return fields + +`send_text` returns `focus_target`, `focus_source`, `text_changed`, +`text_after` (secret/password fields redacted), `chars_sent`, and a `hint` +(which steers you to pass `node_path` when nothing is focused). + +## Timing warning + +`delay_after_ms` waits after each event. Keep it to **100–300 ms**. Values +**> 500 ms** are dangerous: the live game world keeps advancing during the wait +— enemies move, timers tick, damage accumulates — so a long delay can change +the state you were about to assert on. + +## Focus and routing + +Mouse events route through `push_input` with `position` + `global_position` set +— sufficient for viewport / `CanvasLayer` / GUI hit-testing with **no OS-level +window focus or mouse warp**, so parallel sessions driving multiple game +instances never fight over the OS mouse. Window foregrounding is a separate, +explicit opt-in (`force_foreground_game`) — input synthesis never needs it. +Every event returns per-event diagnostics for debugging what landed. diff --git a/addons/godot_mcp_toolkit/CompanionSkills/godot-mcp-toolkit/references/parallel-sessions.md b/addons/godot_mcp_toolkit/CompanionSkills/godot-mcp-toolkit/references/parallel-sessions.md new file mode 100644 index 0000000..f7fead0 --- /dev/null +++ b/addons/godot_mcp_toolkit/CompanionSkills/godot-mcp-toolkit/references/parallel-sessions.md @@ -0,0 +1,76 @@ +# Parallel sessions (multi-agent concurrency) + +Multiple agents can connect to the same Godot editor at once. Two systems +prevent races automatically — you don't need to do anything special, but +understanding the behaviour helps you work faster. Under contention responses +get **slower, not broken**. + +## What happens behind the scenes + +- **Mutation lock** — only one mutation executes at a time. If yours is queued, + you see slightly longer response times, not errors. +- **Scene lease** — one agent at a time "owns" the active editor tab. If you are + working on a different scene than the current tab holder, your scene commands + queue until the tab becomes available (up to ~8 seconds before a steal + occurs). + +## The precise routing rule + +Requests are ordered by three layers, in this order: + +1. **Scene-lease routing happens first.** A tab-dependent command — *including a + read* — that targets a scene which is not the active tab queues for the lease + before anything else. +2. **Read-only commands bypass both locks.** Reads that do not depend on the + active tab run immediately. +3. **Mutations serialize one-at-a-time (FIFO).** Two mutations never overlap; + they run in arrival order. + +So a read that targets a *non-active* scene still waits for the lease, while a +read that does not is free. + +## How to tell you are contended + +When you call `scene_open` and another session holds the active tab, the +response includes a hint such as: + +> "Note: another session is currently editing res://forest.tscn..." + +That is your signal to reorganise work order — do tab-independent work first. +Slow responses under multi-session load are contention, not failure. + +## Tab-independent work (no waiting) + +These execute immediately regardless of scene contention: + +- Reading and writing scripts +- Managing files and folders +- Querying and setting project settings +- Managing autoloads +- Reading console output + +## Tab-dependent work (may queue) + +These need the active tab and may wait under contention: + +- Creating, deleting, or modifying nodes in a scene +- Reading scene trees or node properties +- Saving scenes + +## Best practices + +1. **Check before creating.** Read existing files/scenes before creating new + ones — another agent may have already made what you need. Survey with + `scene_get_tree` and `script_read`. +2. **Use canonical paths.** Put scripts in `scripts/`, scenes in `scenes/`. + Don't create files at the project root when a subdirectory exists — parallel + agents that don't coordinate will create duplicates. +3. **Front-load tab-independent work.** Write scripts, set up autoloads, and + configure project settings before creating scene nodes. This minimises your + contention window. +4. **Batch properties.** Use `node_set_property` batch mode to set many + properties in one call. Each tool call is one contention point, so fewer + calls means less waiting. +5. **Don't fight the lease.** If your commands are taking many seconds, that is + the lease system working. Switch to tab-independent tasks and come back to + scene work later. diff --git a/addons/godot_mcp_toolkit/CompanionSkills/godot-mcp-toolkit/references/type-wrappers.md b/addons/godot_mcp_toolkit/CompanionSkills/godot-mcp-toolkit/references/type-wrappers.md new file mode 100644 index 0000000..14e16c3 --- /dev/null +++ b/addons/godot_mcp_toolkit/CompanionSkills/godot-mcp-toolkit/references/type-wrappers.md @@ -0,0 +1,63 @@ +# Type wrappers for `node_set_property` + +`node_set_property` (and its `batch` form) coerces plain scalars automatically: +plain numbers, strings, and booleans need **no** wrapper. Engine types must be +passed as a `{type: "...", ...}` object. An unknown `type` tag is rejected with +the list of supported tags, so a typo fails loudly rather than silently. + +## Scalar and vector types + +| Type tag | Format | Example | +|----------|--------|---------| +| `Vector2` | `{x, y}` | `{type: "Vector2", x: 100, y: 200}` | +| `Vector3` | `{x, y, z}` | `{type: "Vector3", x: 1, y: 2, z: 3}` | +| `Vector4` | `{x, y, z, w}` | `{type: "Vector4", x: 1, y: 2, z: 3, w: 4}` | +| `Vector2i` | integer `{x, y}` | `{type: "Vector2i", x: 10, y: 20}` | +| `Vector3i` | integer `{x, y, z}` | `{type: "Vector3i", x: 1, y: 2, z: 3}` | +| `Color` | `{r, g, b, a}` (a defaults to 1.0) | `{type: "Color", r: 1, g: 0, b: 0}` | +| `Rect2` | `{x, y, w, h}` | `{type: "Rect2", x: 0, y: 0, w: 100, h: 50}` | +| `Rect2i` | integer `{x, y, w, h}` | `{type: "Rect2i", x: 0, y: 0, w: 64, h: 64}` | +| `NodePath` | `{path}` | `{type: "NodePath", path: "../Player"}` | + +## Resources + +| Type tag | Format | Notes | +|----------|--------|-------| +| `Resource` | `{path}` | Bind an existing resource file: `{type: "Resource", path: "res://icon.png"}` | +| `NewResource` | `{class, properties}` | Build an inline sub-resource, e.g. a shape: `{type: "NewResource", class: "CircleShape2D", properties: {radius: 16}}` | + +## Transforms + +- `Transform2D`: `{type: "Transform2D", x_axis: {x, y}, y_axis: {x, y}, origin: {x, y}}` +- `Transform3D`: `{type: "Transform3D", x_axis: {x, y, z}, y_axis: {x, y, z}, z_axis: {x, y, z}, origin: {x, y, z}}` + +## Packed arrays + +Element values are themselves wrapped: + +- `PackedVector2Array`: `{type: "PackedVector2Array", values: [{type: "Vector2", x: 0, y: 0}, {type: "Vector2", x: 1, y: 1}]}` +- `PackedVector3Array`: `{type: "PackedVector3Array", values: [{type: "Vector3", x: 0, y: 0, z: 0}, ...]}` +- `PackedColorArray`: `{type: "PackedColorArray", values: [{type: "Color", r: 1, g: 0, b: 0}, ...]}` + +## Layer masks + +`LayerMask` sets a collision/visibility bitmask by layer **number** or by +**name** (names come from a prior `layer_names_set` call). `category` defaults +to `2d_physics`. + +- By number: `{type: "LayerMask", category: "2d_physics", layers: [1, 3]}` +- By name: `{type: "LayerMask", category: "2d_physics", layers: ["player", "enemy"]}` + +## Compound property paths + +- `/` separates sub-properties: `property: "position:x"` sets one component. +- `:` chains into a sub-resource: `property: "material:shader_parameter/value"`. +- `make_unique: true` (single or per-batch-entry) duplicates a shared external + `.tres` before editing it, so the change does not leak into other users of + that resource. + +## Anchor caveat + +Setting `anchors_preset` alone may not apply the corresponding offsets. Set the +`anchor_*` / `offset_*` sides explicitly, or use `control_set_layout` (anchor +preset + optional margins in one call, returns the final rect). diff --git a/addons/godot_mcp_toolkit/CompanionSkills/mcp-extension-creator/SKILL.md b/addons/godot_mcp_toolkit/CompanionSkills/mcp-extension-creator/SKILL.md new file mode 100644 index 0000000..87fef40 --- /dev/null +++ b/addons/godot_mcp_toolkit/CompanionSkills/mcp-extension-creator/SKILL.md @@ -0,0 +1,499 @@ +--- +name: mcp-extension-creator +description: Guide the user through creating MCP toolkit extensions — distributable addons that add custom tools to the Godot MCP Toolkit. +--- + +You are a Godot MCP Toolkit extension authoring assistant. Help the user create +third-party extensions that add custom MCP tools to the toolkit. Default to +GDScript examples unless the user explicitly asks for C#. When the user asks you to +build an extension, open with a one-line offer of guided design (see "Guided +authoring mode"), then build it — the full guided flow is opt-in and never blocks. + +## Quick start — a complete working extension + +Paste this, rename the class, save it under `addons//`, and +reload (alt-tab to the editor or call `extensions_refresh`). It registers one +tool, `notes_write`, that writes a markdown note to a `res://` path — with a path +guard, the required-parameter check, and both response envelopes. + +```gdscript +@tool +class_name MCPToolkitNotesExample +extends MCPToolkitExtension +## Writes a markdown note to a res:// path. + +func register(registry: MCPToolkitCommandRegistry, server: Node) -> void: + var opts := MCPToolkitExtensionOptions.new("Write a markdown note to a res:// path") \ + .mark_scene_independent() \ + .guard_project_path("file_path") \ + .with_input_schema({"type": "object", + "properties": {"file_path": {"type": "string"}, "content": {"type": "string"}}, + "required": ["file_path", "content"]}) + registry.add("notes.write", _write, opts) + +## Writes [param content] to [param file_path]. Returns the written path. +func _write(params: Dictionary) -> Dictionary: + var missing: Variant = MCPToolkitError.require(params, ["file_path", "content"]) + if missing != null: + return missing + var file_path: String = params["file_path"] + var content: String = params["content"] + var file := FileAccess.open(file_path, FileAccess.WRITE) + if file == null: + return MCPToolkitError.fail("WRITE_FAILED", "could not write " + file_path) + file.store_string(content) + return MCPToolkitSuccess.ok({"path": file_path}) +``` + +You register the **wire** name `notes.write` (with a dot); the client sees the +tool as `notes_write` (dot → underscore). The fully-commented version, with +schema descriptions and detailed doc comments, is `references/example-extension.gd`. + +## Extension structure + +Extensions are distributable addons discovered via reflection. Each lives in its +own `addons//` directory. A GDScript extension is one `.gd` file: + +``` +addons// +└── .gd # class_name extends MCPToolkitExtension +``` + +The skeleton — the four moving parts are the class header, the options builder, +`registry.add`, and the handler: + +```gdscript +@tool +class_name +extends MCPToolkitExtension +## + +func register(registry: MCPToolkitCommandRegistry, server: Node) -> void: + var opts := MCPToolkitExtensionOptions.new("") \ + .mark_read_only() \ + .with_input_schema({"type": "object", "properties": {}}) # see the crib below + registry.add(".", _handler, opts) + +## +func _handler(params: Dictionary) -> Dictionary: + var missing: Variant = MCPToolkitError.require(params, ["param_name"]) + if missing != null: + return missing + return MCPToolkitSuccess.ok({"data": params["param_name"]}) +``` + +Every `mark_*` / `with_*` builder call and when to use it is in the decision table +below. + +## C# extensions + +C# cannot extend the GDScript base class, so the mechanics differ and a build +step is required. Two gotchas dominate: + +- **File name must match the class name** (`MCPToolkitMyTools.cs` for class + `MCPToolkitMyTools`) — a mismatch silently fails to register. +- **Class name must start with `MCPToolkit`** — that prefix is the C# discovery + marker. + +Full C# template, registry-factory options, discovery workflow, cancellation, +and `///` doc-comment guidance: `references/csharp-extensions.md`. + +## MCPToolkitExtensionOptions — decision table + +Configure a tool with the builder, created via +`MCPToolkitExtensionOptions.new("description")` — the description (shown to the LLM +in `tools/list`) is mandatory. Every `mark_*` / `with_*` method returns `self`, so +calls chain. A `mark_*` verb takes **no argument** — *call it to enable*, *omit it +for the default* (never true/false; there is no author-facing `is_*` form). + +**Behavioral flags — get these right (default is stated first):** + +| Verb | Default (omitted) | Set it when | Consequence if wrong | +|------|-------------------|-------------|----------------------| +| `mark_read_only()` | tool treated as **mutating** | the tool only reads state, never writes | Omitted on a real reader: it serializes behind the mutation lock (slower under multi-session) and is excluded under `GODOT_MCP_READ_ONLY=1`. Set on a writer: its writes bypass serialization → races. | +| `mark_destructive()` | non-destructive | the tool deletes or irreversibly changes data | Surfaces `destructiveHint` to the client. **Mutually exclusive with `mark_read_only()`** — set both and the tool is treated as mutating and a warning logs. | +| `mark_scene_independent()` | **scene-dependent** (waits for the scene lease) | the handler touches only file paths / engine singletons, not `EditorInterface.get_edited_scene_root()` | Omitted on a file-only tool: it needlessly queues behind the scene lease when another session holds the tab. Set on a scene-reading tool: it may read the wrong scene under contention. | +| `mark_exclusive_execution()` | not exclusive | a read-only tool has side effects that must not overlap mutations (e.g. starts/stops a process) | Omitted where needed: the side effect can interleave with a concurrent mutation. Rarely needed. | +| `mark_cancellable()` | not cancellable (1-arg handler) | the tool does long-running work and should bail early on cancel | **Changes the handler signature** — a cancellable handler takes a 2nd arg `ctx: MCPToolkitToolContext`. Set it but keep a 1-arg handler and the dispatcher's 2-arg call fails. See `references/cancellation.md`. | + +**Light touch — defaults usually fine:** + +| Verb | Default | Note | +|------|---------|------| +| `mark_idempotent()` | not idempotent | pure client hint (`idempotentHint`); set if repeating with the same input has the same effect. | +| `with_timeout_ms(ms)` | 30000 | floor 1000, cap 300000; raise for external services. | +| `with_success_hint(text)` | none | next-step guidance auto-injected into successful responses. | +| `with_min_godot_version(v)` | none | out-of-range → tool never registered (invisible). Also raises the doc-comment BBCode floor to `v` (see below). | +| `with_max_godot_version(v)` | none | newer than `v` → not registered. Format `"major.minor"`. | + +**Schema and group are their own decision, not a value tweak:** + +- `with_input_schema(schema)` — the parameter contract (JSON Schema). See the + input-schema crib below. +- `with_group(name, description="", keywords=[])` — puts the tool behind + `discover_tools` (on-demand). Ungrouped tools are eager (visible at startup). + **Keyword gotcha:** fuzzy match is substring with a minimum length of 3, so + short domain terms (`"2d"`, `"3d"`, `"ui"`, `"ai"`) must be explicit keywords — + e.g. `with_group("my_2d_tools", "...", ["2d", "sprite", "tilemap"])`. + +**Path guards belong on the builder too:** + +- `guard_project_path(param)` / `guard_user_path(param)` — declarative `res://` / + `user://` guard; dispatch rejects a traversal path with `PATH_DENIED` before the + handler runs. + +## Path safety and untrusted output + +The LLM is the untrusted caller. Two rules keep an extension safe. + +**1. Guard every LLM-supplied path** so a traversal/escape path +(`res://../../secret`, `/etc/passwd`, drive letter, UNC) can't reach your file +ops. Prefer the **declarative** guard — dispatch rejects a bad path with +`PATH_DENIED` before your handler runs: + +```gdscript +var opts = MCPToolkitExtensionOptions.new("Read a config file") \ + .mark_read_only() \ + .guard_project_path("file_path") # res:// (.guard_user_path for user://) +``` + +For a path the declarative guard doesn't fit (e.g. a legitimate absolute path), +guard imperatively with `FileGuard.resolve_safe`: + +```gdscript +const FileGuard = preload("res://addons/godot_mcp_toolkit/security/file_guard.gd") +var guard := FileGuard.resolve_safe(params.get("file_path", "")) +if guard["error"] != null: # returns {error, reason} on a rejected path + return MCPToolkitError.fail("PATH_DENIED", str(guard["reason"])) +``` + +**2. Wrap untrusted content you return.** There is no automatic output wrapping — +**you must do it**. Whenever a response field carries bytes from **outside your +own code** (a file you read, project/scene data, echoed user input, an external +tool's output), wrap it so the LLM treats it as data, not instructions: + +```gdscript +const Untrusted = preload("res://addons/godot_mcp_toolkit/security/untrusted.gd") +# kind + source are labels; body is the untrusted text. +# JSON.stringify(...) a Dictionary/Array body before wrapping. +return MCPToolkitSuccess.ok({ + "content": Untrusted.wrap("config", file_path, text), +}) +``` + +Do **not** re-wrap content a built-in tool already returned — it is wrapped once, +at origin, and re-wrapping corrupts the envelope. Skipping the wrap on +genuinely-untrusted output is a prompt-injection hole, so wrap by default whenever +the bytes didn't originate in your own code. + +## Error handling + +Every handler returns a Dictionary — never let an exception propagate. Use the +contract helpers, which stamp the required keys for you: + +```gdscript +# Success envelope (guarantees "success": true): +return MCPToolkitSuccess.ok({"data": result_value}) + +# Failure — code, message, and an optional recovery hint: +return MCPToolkitError.fail("NODE_NOT_FOUND", "no node at " + path, + "Use scene_get_tree to list valid node paths.") +``` + +`MCPToolkitError.fail()`, `MCPToolkitSuccess.ok()`, and `MCPToolkitError.require()` +are globals (`class_name`) — no preload needed. The optional third argument to +`fail()` is a recovery **hint** the LLM uses to self-correct; some codes carry a +default hint automatically. `require()` is the shared required-parameter check: + +```gdscript +# Returns an INVALID_PARAMS error dict, or null if all keys are present: +var missing: Variant = MCPToolkitError.require(params, ["file_path"]) +if missing != null: + return missing +``` + +**Common error codes** (the full authoritative set is `MCPToolkitError.CODES`): +`INVALID_PARAMS` (missing/bad parameter), `INVALID_PATH`, `INVALID_VALUE`, +`NOT_FOUND`, `NODE_NOT_FOUND`, `PATH_DENIED` (path-guard rejection), +`FILE_TOO_LARGE`, `TIMEOUT`, `UNSUPPORTED`, `HEADLESS_UNSUPPORTED`, `INTERNAL`. +`MCPToolkitError.fail()` asserts (in debug builds) that your code is one of these, +so an undeclared code trips the assert — always use a code from the set. + +## Doc comments (`##` for GDScript) + +An authored extension declares `class_name` and extends `MCPToolkitExtension`, so +it appears in the in-editor class reference (Help / F1). Its `##` comments are a +**user-facing** surface — document it well. + +- **Where:** a `##` class docstring immediately after `extends`, and a `##` block + on each registered command handler. +- **Brief + detail:** the first `##` paragraph is the brief (shown in tooltips). + Separate a detailed body with a single blank `##` line; `[br]` forces a break. +- **Contract, not types:** reference each parameter with `[param name]` and state + the result with a "Returns …" sentence — describe meaning and contract, never + restate the signature's types. +- **Anti-patterns:** no process history ("added in …", "changed by …"), no + `@author`, no caller lists, no parrot comments restating the next line. + +**Use only BBCode tags that render on Godot 4.2** — the Help renderer is the +*user's* editor, and your doc comments render on every version the extension runs on: + +- **Allowed:** `[ClassName]`, `[param]`, `[member]`, `[method]`, `[signal]`, + `[constant]`, `[enum]`, `[annotation]`; `[b]`, `[i]`, `[u]`, `[code]`, + `[codeblock]` (plain), `[br]`, `[url]`, `[kbd]`. +- **Forbidden (4.3+ forms — render as literal text on 4.2):** `[codeblock lang=…]` + (use plain `[codeblock]`), `[lb]` / `[rb]`, `[constructor]`, `[operator]`, and + the `@deprecated: msg` / `@experimental: msg` forms (use the bare `@deprecated` / + `@experimental` badge and explain in prose). + +The floor rises only when you declare `with_min_godot_version(v)` — then the +extension can't run below `v`, so its doc comments may use tags valid from `v` up. + +For C#, write `///` XML-doc for readers of your distributed source, but note Godot +does **not** surface C# `///` in the Help UI (only GDScript `##` is harvested) — +see `references/csharp-extensions.md`. For GDScript, the class docstring is the +reliably Help-visible surface; keep a handler docstring regardless, since it +documents the code for anyone reading the file. + +## Type coercion for complex parameters + +The MCP client sends JSON, so Godot receives basic types — coerce them to engine +types inside the handler (`Vector2(params.get("x", 0.0), …)`, `ResourceLoader.load`, +etc.). Full recipes: `references/type-coercion.md`. + +## GDScript requirements + +- `@tool` annotation mandatory (without it, `script.new()` fails in editor). +- `class_name` can be anything — discovery is by base class, not by name. The + `MCPToolkit` prefix is a recommended convention for namespace hygiene (so your + class never collides with user code), but not required for GDScript. +- `extends MCPToolkitExtension` **directly** — multi-level inheritance (an + intermediate base class) is NOT supported; share code via composition (a static + helper class) instead. +- `register()` signature must match exactly: + `func register(registry: MCPToolkitCommandRegistry, server: Node) -> void:` +- File name does NOT need to match the class name (unlike C#). + +## Cancellation + +For long-running tools, call `.mark_cancellable()` — the handler then takes a +2-arg form receiving an `MCPToolkitToolContext` to observe cancellation (poll +`ctx.is_cancelled()` or connect its `cancelled` signal). Full setup, rules, and +the C# form: `references/cancellation.md`. + +## Tool groups + +Commands registered with `.with_group()` are lazily loaded — they only become +visible to the LLM after `discover_tools` is called. Commands without a group are +eager (visible from startup). Choose based on how specialized the tool is: +general-purpose tools ungrouped (always available), niche tools grouped to reduce +tool-list noise. + +When several groups are needed, load them in a **single** `discover_tools` call — +`discover_tools(request: ["runtime_advanced", "input_map"])`, not one call per +group. Each call fires a `tools/list_changed` notification that forces the LLM to +re-fetch the whole tool list, so batching keeps that to one refresh. + +## Distributable addon layout + +``` +addons// +├── .gd # class_name extends MCPToolkitExtension +└── README.md # State godot-mcp-toolkit dependency +``` + +**No `plugin.cfg` required** for simple extensions — the toolkit discovers them via +reflection. A complex extension needing editor UI can add one, but tool +registration still goes through `MCPToolkitExtension`. For AssetLib submission: + +- **State `godot-mcp-toolkit` as a required dependency** — in your README and + AssetLib description: *"Install from the Godot AssetLib (search 'Godot MCP + Toolkit') or from GitHub Releases"*. Include usage examples. +- **Test with the toolkit installed and without.** Users may install your + extension first — handle the missing-toolkit case: **GDScript** fails loudly at + parse time (`extends MCPToolkitExtension` — the Output panel names the missing + class, no extra code needed); **C#** compiles fine and silently no-ops (document + this; an `EditorPlugin` wrapper can `push_warning()` — see + `references/csharp-extensions.md`). + +## Naming rules + +- **GDScript class names:** can be anything — discovery is by the + `extends MCPToolkitExtension` base class. (`MCPToolkit` prefix recommended for + namespace hygiene.) +- **C# class names:** must start with `MCPToolkit` (e.g., `MCPToolkitPhysicsTools`) + — the discovery marker, since C# cannot extend the GDScript base class. +- **Command names — wire name vs. MCP tool name:** use the `.` + pattern (e.g., `physics.list_bodies`). You register this **wire** name (with the + dot); the server surfaces it to the LLM with dots replaced by underscores + (`physics_list_bodies`). Register with the dot; call it with the underscore. +- **Reserved namespaces** (rejected at load time): `scene.*`, `script.*`, + `editor.*`, `node.*`, `runtime.*`, `server.*`, `resource.*`, `folder.*`, + `file.*`, `signal.*`, `playtest.*`, `project.*`, `input_map.*`, + `animation.*`, `tilemap.*`, `asset.*`, `save.*`, `meta.*`, `game.*`, + `diff.*`, `autoload.*`, `extensions.*` + +## Hot-reload behavior + +Extensions are monitored at runtime and changes are picked up automatically: + +- **GDScript:** save the file, then alt-tab to the editor (or call + `extensions_refresh`). Detection is immediate via `EditorFileSystem.filesystem_changed`. +- **C#:** run `dotnet build` first (the global class list only updates after a + rebuild), then alt-tab or call `extensions_refresh`. +- **Programmatic scan:** call the `extensions_refresh` MCP tool to force a scan + without editor focus — useful when files are created externally. The watcher + compares method lists and re-registers on change; rapid edits debounce to one + rescan (500ms window). + +> **Godot 4.2 only — editing an existing extension needs an editor restart.** On +> 4.2 the loader reads through the editor cache (a crash-avoidance measure), so an +> in-session **edit** to an already-loaded extension is not applied until you +> restart the editor; `extensions_refresh` returns a restart hint naming the +> changed extension. **Adding** and **removing** extensions still apply live on +> 4.2. Godot 4.3+ applies add / edit / remove all live. + +## Concurrency + +When multiple agents share one editor, two mechanisms prevent races and your tool +opts into both by default: the **mutation lock** (serialises non-read-only +commands — opt out with `mark_read_only()` only if the tool truly never writes) +and the **scene lease** (tab-dependent commands run on the correct scene — opt out +with `mark_scene_independent()` if the tool touches only file paths / engine +singletons). In single-session usage (the common case), both are no-ops. See the +decision table for the consequence of setting either flag wrong. + +## Headless compatibility + +Extensions run identically whether the editor is normal or headless +(`godot --headless --editor`, used for CI/automation) — reflection needs no +display. Most tools work headless unchanged (file, scene-tree, node, resource, +`ClassDB`, project-settings ops — the headless editor has a full `SceneTree` and +`EditorInterface`). + +A tool **cannot** run headless if it needs a rendered viewport (screenshots, pixel +reads), a running game with a display, or a native UI dialog. Some fail +**silently** — a viewport capture returns a blank image, not an error. Guard those +so the failure is explicit: + +```gdscript +func _capture(params: Dictionary) -> Dictionary: + if DisplayServer.get_name() == "headless": + return MCPToolkitError.fail("HEADLESS_UNSUPPORTED", + "This tool needs a rendered viewport; run the editor with a display.") + # ... capture logic ... + return MCPToolkitSuccess.ok({"data": image_data}) +``` + +C# uses the same check via `DisplayServer.GetName() == "headless"`. + +**Only guard when the failure would be silent.** Tools that already error loudly +headless (e.g. depending on a game process that can't launch) don't need it. The +built-in tools follow this — several (viewport screenshots, texture generation, +game start, console capture, hot-reload) return a self-explaining headless +response rather than silent junk. + +## Exporting your game + +On export, the toolkit's plugin strips the addon, your extension's non-script +files, and `res://.mcp.json`. In binary-token script mode (Godot 4.3+ default) +your `.gd` ships as an inert, orphaned `.gdc` (never loaded, no runtime effect) +and the plugin warns. For a clean strip, see `references/exporting.md`. + +## Getting listed in the catalog + +The discoverable extension catalog is a maintainer-curated list — an entry lives in +the toolkit's catalog, not your extension's code. To get listed, file a +catalog-listing request on the toolkit repo; full field requirements and the route +are in "Getting listed in the extension catalog" in +`addons/godot_mcp_toolkit/docs/extending.md` (shipped with the addon). + +## Verify your extension + +Three checks confirm a new extension works end-to-end: + +1. **It loads** — register the tool, then validate GDScript (run + `validate_gdscript.sh` from your project root). No parse errors in the Output + panel, and the loader discovered the class (or `extensions_refresh` reports it). +2. **It registers** — the tool appears via `discover_tools` (grouped) or in the + tool list (ungrouped). +3. **It works** — verify interactively: one happy-path call succeeds, one bad-input + call returns your error code. + +## Updating your extension + +- **After an edit:** hot-reload picks up added/removed tools automatically; if the + client hasn't noticed, call `extensions_refresh` (see "Hot-reload behavior" for + the Godot 4.2 edit-restart caveat). +- **After a toolkit update:** re-run the 3-step self-check, re-read the decision + table for new `mark_*` / `with_*` verbs, and re-check your `min`/`max` Godot + gates against newly-supported versions. +- **When your own change is breaking for your users** (a parameter rename/removal): + version your extension and note it in your README. + +## Input-schema crib + +`with_input_schema` takes a JSON Schema describing your parameters. The essentials: + +```json +{ + "type": "object", + "properties": { + "file_path": {"type": "string", "description": "res:// path to the note"}, + "count": {"type": "integer", "default": 10}, + "mode": {"type": "string", "enum": ["fast", "full"]}, + "opts": {"type": "object", "properties": {"deep": {"type": "boolean"}}} + }, + "required": ["file_path"] +} +``` + +- `properties`: one entry per parameter, each a `type` (`"string"`, `"integer"`, + `"number"`, `"boolean"`, `"object"`, `"array"`) plus a `description`. +- `required`: array of parameter names the caller must supply. +- `enum`: restricts a value to a fixed set. `default`: the value assumed when omitted. + +The schema is advertised to the client but **not runtime-validated** — declare every +parameter your tool reads, and validate inside the handler (`MCPToolkitError.require` for required params; type reads explicitly per the pitfall below). + +## Guided authoring mode (opt-in) + +By default you build the extension directly — "build an extension that …" means +just build it, no interview. **Guided mode is opt-in** and must never block an +autonomous run on a human interview. + +- **Offer it once, in one line**, when you begin autonomous extension work — e.g. + *"I'll scaffold this now — say 'guide me' if you'd rather walk through the design + decisions together."* Explicit triggers that switch to guided mode: "guide me", + "walk me through it", "step by step". +- **Adaptive depth, not a quick/thorough pre-fork.** Start lean (propose each + setting with a one-line why) and offer more on demand ("want me to explain why + this matters?"). An optional, skippable "how deep?" first question is fine. + +**Flow — intent Q&A → one annotated draft → confirm or adjust by line:** + +1. **Intent Q&A** (short): ask only what you can't infer — what the tool does, why, + and how it behaves. Keep it to a handful of questions. +2. **Draft the complete config once:** command name, description, every annotation + with a one-line rationale, the input schema, the group choice, and the `##` + class + handler docstrings (so the tool is Help-ready by construction). Apply + the decision table per-tool with a tool-specific rationale — do not confirm each + annotation serially (they interact: `read_only` vs `destructive`, `cancellable` + changing the signature). +3. **User confirms or points at specific lines** to change. Adjust and re-present. + +## Common pitfalls + +| Symptom | Cause | Fix | +|---------|-------|-----| +| `progress_dialog.cpp` errors / editor wedge on save | Handler calls `EditorInterface.save_scene()` directly (re-enters `Main::iteration()` → wedge/crash) | **`.gd`:** `await MCPToolkitSafeSceneOps.save_scene()` (or `queue_save`/`check_save` for fire-and-forget). **`.cs`:** `id = registry.Call("queue_save", path)` then poll `registry.Call("check_save", id, clear)`, or mutate-only and let the client call `editor_save_scene`. Never call `EditorInterface.save_scene[_as]()` directly. | +| Extension not discovered | Missing `@tool` (GDScript) or `[Tool]` (C#) | Add the annotation and save/rebuild | +| GDScript extension not found | Not extending `MCPToolkitExtension` | Add `extends MCPToolkitExtension` | +| "register() not overridden" warning | Wrong method signature | Use exact signature: `registry: MCPToolkitCommandRegistry, server: Node` | +| Compile error / "inferred as Variant" on a `:=` | `:=` can't infer from **any** Variant source — `params[...]`, a Variant-returning call like `MCPToolkitError.require`, an untyped-Array element | Type the local: `var x: String = params["x"]`, `var missing: Variant = MCPToolkitError.require(...)`. Never `:=` from a Variant. | +| Command rejected at load time | Using a reserved namespace | Choose a custom namespace (e.g., `mytools.action`) | +| Registered `notes.write` but the tool list shows `notes_write` | The server maps the wire name `a.b` to the MCP tool name `a_b` | Expected — register with the dot, call with the underscore | +| Grouped tool not visible to LLM | Grouped tools load on demand | User (or agent) calls `discover_tools` | +| Hot-reload not detecting changes | Editor not focused after an external edit | Alt-tab to the editor or call `extensions_refresh` | +| New tool not surfaced after registering | Some MCP clients cache the tool list | Call `extensions_refresh` (or `discover_tools` for a grouped tool); if the client still doesn't show it, run `/mcp` reconnect. In `claude -p`, on-demand `discover_tools` activation and `extensions_refresh` both work — no interactive session required. | +| Tool returns a blank image or junk data when headless | Needs a rendered viewport, running game, or native UI | Guard with `DisplayServer.get_name() == "headless"` → return `HEADLESS_UNSUPPORTED` | diff --git a/addons/godot_mcp_toolkit/CompanionSkills/mcp-extension-creator/references/cancellation.md b/addons/godot_mcp_toolkit/CompanionSkills/mcp-extension-creator/references/cancellation.md new file mode 100644 index 0000000..537c090 --- /dev/null +++ b/addons/godot_mcp_toolkit/CompanionSkills/mcp-extension-creator/references/cancellation.md @@ -0,0 +1,61 @@ +# Cooperative cancellation + +For long-running tools (external API calls, heavy processing), opt into +cooperative cancellation so the handler exits early when the caller cancels. +Most tools do **not** need this — reach for it only when a tool does slow work +that should be able to bail out mid-flight. + +## Enabling it + +Call `.mark_cancellable()` on the options. This changes the handler contract: +a cancellable handler takes a **second argument**, a `MCPToolkitToolContext`. + +```gdscript +var opts := MCPToolkitExtensionOptions.new("Fetch data from an external API") \ + .with_timeout_ms(60000) \ + .mark_cancellable() +registry.add("weather.fetch", _handle_fetch, opts) +``` + +> If you set `mark_cancellable()` but keep a one-argument handler, the dispatcher's +> two-argument call fails. The two must match. + +## Observing cancellation + +The context exposes two ways to notice a cancel — use whichever fits the work: + +- **Reactive** — connect the `cancelled` signal to abort an in-flight operation. +- **Polling** — call `ctx.is_cancelled()` between discrete steps. + +```gdscript +## Fetches weather data, aborting early if the call is cancelled. +## +## Returns a success envelope with the payload, or an empty dict if cancelled +## mid-flight. [param params] carries the request; [param ctx] is the +## per-invocation cancellation context. +func _handle_fetch(params: Dictionary, ctx: MCPToolkitToolContext) -> Dictionary: + # Reactive: abort the HTTP request the moment cancellation is requested. + ctx.cancelled.connect(_http_request.cancel_request) + + var result = await _do_fetch(params.get("query", "")) + + # Polling: check between steps and return early. + if ctx.is_cancelled(): + return {} + + return MCPToolkitSuccess.ok({"data": result}) +``` + +## Rules + +- **Do not store the context.** It is scoped to a single invocation — the + dispatcher owns it, and holding a reference past the handler's return is a bug. +- **Return promptly once cancelled.** A cancelled handler should stop work and + return; an empty dict or an `MCPToolkitError.fail("FAILED", "cancelled")` are + both fine — the point is to stop, not to keep computing. + +## C# + +The context arrives as a second `GodotObject` and is driven by name +(`ctx.Connect("cancelled", …)`, `(bool)ctx.Call("is_cancelled")`). See +`references/csharp-extensions.md`. diff --git a/addons/godot_mcp_toolkit/CompanionSkills/mcp-extension-creator/references/csharp-extensions.md b/addons/godot_mcp_toolkit/CompanionSkills/mcp-extension-creator/references/csharp-extensions.md new file mode 100644 index 0000000..d3c69a4 --- /dev/null +++ b/addons/godot_mcp_toolkit/CompanionSkills/mcp-extension-creator/references/csharp-extensions.md @@ -0,0 +1,125 @@ +# C# extensions + +C# extensions add the same tools as GDScript ones, but C# cannot extend a +GDScript base class, so the mechanics differ. Prefer GDScript unless the project +is a .NET project — C# needs a build step before the toolkit can see the tool. + +## Two hard gotchas (also in the main skill) + +1. **File name must match the class name** — `MCPToolkitMyTools.cs` for class + `MCPToolkitMyTools`. Godot's source generators only emit script metadata when + these match; a mismatch silently fails to register. +2. **Class name must start with `MCPToolkit`** — this prefix is the C# discovery + marker (C# cannot extend `MCPToolkitExtension`, so the loader recognizes an + extension by the prefix on a `[GlobalClass]`). + +## Template + +C# uses `RefCounted` directly with duck typing. The options builder is obtained +from the registry factory (C# cannot instantiate the GDScript builder class). + +```csharp +using Godot; +using Godot.Collections; + +[Tool, GlobalClass] +public partial class MCPToolkitNotesTools : RefCounted +{ + /// Registers this extension's tools. Called once at load. + public void Register(GodotObject registry, Node server) + { + var opts = registry.Call("create_extension_options", + "Write a markdown note to a res:// path").AsGodotObject(); + opts.Call("guard_project_path", "file_path"); + registry.Call("add", "notes.write", + new Callable(this, MethodName.Write), opts); + } + + /// Writes a note. Returns a success envelope or an error dict. + public Dictionary Write(Dictionary parameters) + { + // Parameter extraction, validation, and business logic. + // Return the same {"success": bool, ...} envelope as GDScript. + return new Dictionary { { "success", true }, { "data", "written" } }; + } +} +``` + +## Requirements + +- `[Tool]` attribute mandatory — without it the .NET object is not instantiated + in the editor (method calls return null). +- `[GlobalClass]` attribute mandatory — makes the class visible to + `ProjectSettings.get_global_class_list()`. +- `partial class` extending `RefCounted` (not `MCPToolkitExtension`). +- Public `Register(GodotObject registry, Node server)` method. +- Handler methods accept and return `Godot.Collections.Dictionary`. +- Callables: `new Callable(this, MethodName.Method)` or + `Callable.From(Method)`. + +## Options builder from the registry factory + +C# cannot construct the GDScript builder directly, so call the factory and drive +it by name. Every `mark_*` / `with_*` verb works the same as GDScript. + +```csharp +var opts = registry.Call("create_extension_options", + "What this tool does").AsGodotObject(); +opts.Call("mark_read_only"); +opts.Call("mark_idempotent"); +registry.Call("add", "namespace.action", callable, opts); +``` + +## Discovery workflow (C# needs a build) + +1. Run `dotnet build` from the project root (or click **Build** in the editor). +2. This produces the assembly AND updates `global_script_class_cache.cfg`. +3. Alt-tab to the editor (triggers a filesystem scan) or call `extensions_refresh`. + +No editor restart required — the hot-reload watcher detects the class change +after the rebuild. If the extension still doesn't appear: verify the file name +matches the class name, verify `[Tool]` and `[GlobalClass]` are present, and +check the class shows up in `.godot/global_script_class_cache.cfg`. + +## Cancellation (C#) + +The context arrives as a second `GodotObject` argument and is driven by name. + +```csharp +public Dictionary HandleFetch(Dictionary parameters, GodotObject ctx) +{ + ctx.Connect("cancelled", Callable.From(OnCancelled)); + // ... work ... + if ((bool)ctx.Call("is_cancelled")) return new Dictionary(); + return new Dictionary { { "success", true }, { "data", result } }; +} +``` + +Do not store the context — it is scoped to a single invocation. + +## Headless + +Same guard as GDScript, via `DisplayServer.GetName() == "headless"`. + +## Exporting + +C# extensions compile into the project's .NET assembly and cannot be stripped +per-class. Guard editor-only work with `if (!Engine.IsEditorHint()) return;` so +nothing runs in a build. + +## Doc comments (`///`) + +Write `///` XML-doc comments on the class and each registered handler — they are +valuable to anyone reading your distributed source. Note, however, that Godot +does **not** harvest C# `///` into the in-editor Help (F1) page — only GDScript +`##` doc comments surface there. So `///` documents the code for human readers of +the file; it does not produce an in-editor reference entry the way `##` does. + +## Graceful missing-toolkit handling + +C# extensions extend `RefCounted`, so they compile fine even when the toolkit is +absent — `Register()` is simply never called (a silent no-op). For an +`EditorPlugin` wrapper, check +`EditorInterface.is_plugin_enabled("godot_mcp_toolkit")` in `_enter_tree()` and +`push_warning()` with install instructions (search "Godot MCP Toolkit" in the +Godot AssetLib). State the dependency prominently in your README either way. diff --git a/addons/godot_mcp_toolkit/CompanionSkills/mcp-extension-creator/references/example-extension.gd b/addons/godot_mcp_toolkit/CompanionSkills/mcp-extension-creator/references/example-extension.gd new file mode 100644 index 0000000..23f928c --- /dev/null +++ b/addons/godot_mcp_toolkit/CompanionSkills/mcp-extension-creator/references/example-extension.gd @@ -0,0 +1,59 @@ +@tool +class_name MCPToolkitNotesExample +extends MCPToolkitExtension +## Example extension that adds a single note-writing tool. +## +## Registers [code]notes.write[/code], which writes a markdown note to a +## [code]res://[/code] path. This is the canonical "what a good extension looks +## like" reference: a path guard, the required-parameter check, the success and +## failure envelopes, and doc comments that surface in the editor's class +## reference. Copy it, rename the class, and adapt the handler.[br] +## [br] +## Example: +## [codeblock] +## notes.write(file_path="res://notes/todo.md", content="- ship it") +## [/codeblock] + + +## Registers this extension's commands with the live [param registry]. +## [param server] is the MCP server node (unused here). Called once at load. +func register(registry: MCPToolkitCommandRegistry, server: Node) -> void: + var opts := MCPToolkitExtensionOptions.new("Write a markdown note to a res:// path") \ + .mark_scene_independent() \ + .guard_project_path("file_path") \ + .with_input_schema({ + "type": "object", + "properties": { + "file_path": {"type": "string", "description": "res:// path to the note (.md)"}, + "content": {"type": "string", "description": "Markdown body to write"}, + }, + "required": ["file_path", "content"], + }) + # Not read-only: the tool writes. Scene-independent: it touches only a file path. + registry.add("notes.write", _write, opts) + + +## Writes a markdown note to disk. +## +## Writes [param content] to the file at [param file_path] (a [code]res://[/code] +## path, guarded against traversal before this runs). Returns a success envelope +## with the written path, or a failure envelope on a missing parameter or an I/O +## error. +func _write(params: Dictionary) -> Dictionary: + var missing: Variant = MCPToolkitError.require(params, ["file_path", "content"]) + if missing != null: + return missing + + var file_path: String = params["file_path"] + var content: String = params["content"] + + var file := FileAccess.open(file_path, FileAccess.WRITE) + if file == null: + return MCPToolkitError.fail("WRITE_FAILED", + "could not open %s for writing" % file_path, + "Check the folder exists — use folder.create first if needed.") + + file.store_string(content) + file.close() + + return MCPToolkitSuccess.ok({"path": file_path, "bytes": content.length()}) diff --git a/addons/godot_mcp_toolkit/CompanionSkills/mcp-extension-creator/references/exporting.md b/addons/godot_mcp_toolkit/CompanionSkills/mcp-extension-creator/references/exporting.md new file mode 100644 index 0000000..905749f --- /dev/null +++ b/addons/godot_mcp_toolkit/CompanionSkills/mcp-extension-creator/references/exporting.md @@ -0,0 +1,29 @@ +# Exporting your game (extension scripts in builds) + +When a user exports their game, the toolkit's export plugin strips the addon, +your extension's **non-script** files, and `res://.mcp.json` in every mode, and +strips extension **scripts** too when Script Export Mode is **Text**. + +## The binary-token caveat (Godot 4.3+) + +In a **binary-token** script mode (the Godot 4.3+ default) the engine compiles +each `.gd` to `.gdc` before the strip can run, so your extension `.gd` ships as +an inert, orphaned `.gdc` — never loaded at runtime, no effect on the game. The +plugin warns at export time and names the leaked extensions. + +This is a long-standing engine limitation (godotengine/godot#4054), not specific +to your extension. Godot 4.2 has no binary-token mode, so extension scripts +always strip cleanly there. + +## For a clean strip + +Either of these removes the orphaned `.gdc`: + +- Set **Script Export Mode** to **Text** in the export preset, or +- Add your extension's `.gd` path to the preset's **Resources → exclude filter**. + +## C# + +C# extensions compile into the project's .NET assembly and cannot be stripped +per-class. Guard editor-only work with `if (!Engine.IsEditorHint()) return;` so +nothing runs in a build. diff --git a/addons/godot_mcp_toolkit/CompanionSkills/mcp-extension-creator/references/type-coercion.md b/addons/godot_mcp_toolkit/CompanionSkills/mcp-extension-creator/references/type-coercion.md new file mode 100644 index 0000000..9de01b7 --- /dev/null +++ b/addons/godot_mcp_toolkit/CompanionSkills/mcp-extension-creator/references/type-coercion.md @@ -0,0 +1,26 @@ +# Type coercion for complex parameters + +When the MCP client sends JSON, Godot receives it as basic types (numbers, +strings, booleans, dictionaries, arrays). Coerce them to engine types inside the +handler as needed. + +```gdscript +# Vector2 from {"x": 10, "y": 20} +var pos := Vector2(params.get("x", 0.0), params.get("y", 0.0)) + +# Vector3 from {"x": 1, "y": 2, "z": 3} +var v := Vector3(params.get("x", 0.0), params.get("y", 0.0), params.get("z", 0.0)) + +# Color from {"r": 1.0, "g": 0.5, "b": 0.0, "a": 1.0} +var color := Color(params.get("r", 0.0), params.get("g", 0.0), + params.get("b", 0.0), params.get("a", 1.0)) + +# Resource path → loaded resource +var res := ResourceLoader.load(params.get("path", "")) +if res == null: + return MCPToolkitError.fail("NOT_FOUND", "Resource not found") +``` + +The same pattern extends to any engine type — read the plain fields off `params` +and construct the type. Validate ranges and existence (a loaded resource, +a resolvable node) before using the value. diff --git a/addons/godot_mcp_toolkit/LICENSE b/addons/godot_mcp_toolkit/LICENSE new file mode 100644 index 0000000..8d542b5 --- /dev/null +++ b/addons/godot_mcp_toolkit/LICENSE @@ -0,0 +1,28 @@ +MIT License + +Copyright (c) 2026 NPGameDev + +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. + +--- + +Note on artwork and branding + +The MIT licence covers the source code. It does not cover the project logo, banner +or name, which are the project's brand identity and are not licensed for reuse. diff --git a/addons/godot_mcp_toolkit/README.md b/addons/godot_mcp_toolkit/README.md new file mode 100644 index 0000000..8e5984b --- /dev/null +++ b/addons/godot_mcp_toolkit/README.md @@ -0,0 +1,101 @@ +# Godot MCP Toolkit + +A Godot 4.2+ editor plugin that runs a localhost WebSocket server so an AI +coding assistant — Claude Code, or any MCP-compatible client — can create +scenes, edit scripts, inspect nodes, and run playtests inside your editor. +The plugin is half the stack: the companion npm package +`@npgamedev/godot-mcp-server` is the bridge your assistant actually talks to. + +Everything runs locally. Nothing leaves your machine. + +## Quick start + +1. **Enable the plugin:** Project Settings → Plugins → **Godot MCP Toolkit** → + check **Active**. The MCP dock appears in the bottom panel and the Output + log prints `[MCPServer] listening on 127.0.0.1:6550` (the port may land + anywhere in 6550–6560; the dock names the live one). That line is the + plugin's own WebSocket server inside the editor. The npm bridge is a + separate process your MCP client starts later, and it dials this port. +2. **Write the client config:** **Project → Tools → MCP Toolkit → + Write .mcp.json** creates the config at your project root. It runs the + bridge through `npx -y @npgamedev/godot-mcp-server`, which fetches it on + first connect, so there is no install step (Node.js 22 or newer required). + If you would rather keep a fixed copy on the machine, + `npm install -g @npgamedev/godot-mcp-server` works too. (Manual + alternative: copy `addons/godot_mcp_toolkit/.mcp.json.template` to your + project root and rename it `.mcp.json`.) +3. **Connect:** launch your MCP client from the project root. It discovers + the plugin and authenticates automatically; the dock's peer count + increments on connection. + +If a step misfires, the troubleshooting guide has a 60-second checklist and a +connectivity probe: + + +## Where things are + +- **The dock** (bottom panel, "MCP") — server status and the bound port, + connected peers, the read-only toggle, audit-log viewer, response limits, + and `.mcp.json` health with a one-click fix. +- **Project → Tools → MCP Toolkit** — quick actions: write or open + `.mcp.json`, regenerate the auth token, show the audit log, open the + plugin's Project Settings. Also in the Command Palette (Ctrl+Shift+P). +- **Info / Help** (button in the dock) — connection details, the registered + tool list, version compatibility, multi-instance guidance, and links, + including a button that opens the shipped compatibility guide. + +## Read-only mode + +For supervised environments (classrooms, CI, demos), flip the dock's +read-only toggle (or set `GODOT_MCP_READ_ONLY=1` in the `.mcp.json` env +block). Every mutating tool is hidden from the agent. Turn it off and +reconnect the client to restore full access — the tool list is decided at +connect time. + +## Documentation + +Shipped with this addon, in `addons/godot_mcp_toolkit/docs/`: + +- [compatibility.md](docs/compatibility.md) — supported Godot versions, + per-tool and headless matrices, degraded behavior, the C# (.NET editor) + requirement, export stripping, and how to disable the plugin safely. +- [security-recommendations.md](docs/security-recommendations.md) — the + security model and recommended client-side permission rules. +- [extending.md](docs/extending.md) — register your own MCP tools in + GDScript (C# supported), with hot-reload, timeouts, and cancellation. +- [multi-instance.md](docs/multi-instance.md) — several editors or git + worktrees side by side. +- [advanced_configuration.md](docs/advanced_configuration.md) — ports, + limits, environment variables, macOS specifics. + +The addon also bundles [agent skills](docs/companion-skills.md) — a workflow +skill and an extension-authoring skill — in +`addons/godot_mcp_toolkit/CompanionSkills/`; copy a skill folder into your +client's skills directory to use them. + +On the web: + +- Tool reference (every tool and operation, generated from the code): + +- Toolkit repository, issues, and full documentation: + + +## Uninstalling + +Disable via Project Settings → Plugins (a dialog offers to clean up +`.mcp.json`), or remove the addon folder. If you delete the folder while the +plugin is still enabled, the cleanup step cannot run — delete `.mcp.json` +from your project root yourself. The shipped compatibility guide covers why +you should not disable the plugin by hand-editing `project.godot`. + +## License + +MIT: the full text travels with the addon in [LICENSE](LICENSE). Third-party +attributions are in [ATTRIBUTIONS.md](ATTRIBUTIONS.md). + +## Trademarks + +Godot and the Godot logo are trademarks of the Godot Foundation. This add-on is +an independent community project with no affiliation with or endorsement from the +Foundation, and it is not an official Godot product. The name describes what the +add-on runs on: the Godot Engine. diff --git a/addons/godot_mcp_toolkit/commands/3d_commands.gd b/addons/godot_mcp_toolkit/commands/3d_commands.gd new file mode 100644 index 0000000..b366bed --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/3d_commands.gd @@ -0,0 +1,323 @@ +@tool +extends RefCounted +## 3d.* command handlers — create 3D primitives, lights, cameras, +## and environment setups in the edited scene. + +const Modules := preload("res://addons/godot_mcp_toolkit/core/modules.gd") +const Coerce = Modules.Coerce +const Helpers = Modules.CommandHelpers + +const _VALID_PRIMITIVES := ["box", "sphere", "cylinder", "capsule", "plane", "prism"] +const _VALID_LIGHT_TYPES := ["directional", "omni", "spot"] +const _VALID_PROJECTIONS := ["perspective", "orthogonal"] +const _VALID_TONEMAPS := ["linear", "reinhardt", "filmic", "aces"] + + +static func register(registry: MCPToolkitCommandRegistry, _server: Node) -> void: + registry.add("3d.create_primitive", func(parameters: Dictionary) -> Dictionary: + return _cmd_create_primitive(parameters) + , MCPToolkitCommandOptions.new()) + registry.add("3d.setup_environment", func(parameters: Dictionary) -> Dictionary: + return _cmd_setup_environment(parameters) + , MCPToolkitCommandOptions.new()) + registry.add("3d.create_light", func(parameters: Dictionary) -> Dictionary: + return _cmd_create_light(parameters) + , MCPToolkitCommandOptions.new()) + registry.add("3d.create_camera", func(parameters: Dictionary) -> Dictionary: + return _cmd_create_camera(parameters) + , MCPToolkitCommandOptions.new()) + + +# -- Helpers ------------------------------------------------------------------ + + +static func _vec3(d: Variant) -> Vector3: + if typeof(d) != TYPE_DICTIONARY: + return Vector3.ZERO + return Vector3(float(d.get("x", 0.0)), float(d.get("y", 0.0)), float(d.get("z", 0.0))) + + +static func _color(d: Variant) -> Color: + return Coerce.color_from_dict(d) + + +static func _path_in_scene(root: Node, node: Node) -> String: + return str(root.get_path_to(node)) + + +static func _resolve_parent(parameters: Dictionary) -> Variant: + var root := Helpers.get_edited_root() + if root == null: + return MCPToolkitError.fail("NO_SCENE", "no edited scene") + var parent_path := str(parameters.get("parent_path", ".")) + parent_path = Helpers.normalize_editor_path(parent_path) + var parent := Helpers.resolve_scene_node(parent_path) + if parent == null: + return MCPToolkitError.fail("NOT_FOUND", + "parent not found: %s" % parent_path, MCPToolkitError.HINT_NODE_PATH) + return parent + + +static func _add_node_undoable(parent: Node, node: Node, root: Node) -> void: + parent.add_child(node) + node.set_owner(root) + MCPToolkitUndoRedoAction.begin("create %s" % node.name, parent) \ + .do_method(parent.add_child.bind(node)) \ + .do_method(node.set_owner.bind(root)) \ + .do_reference(node) \ + .undo_method(parent.remove_child.bind(node)) \ + .commit_recorded() + + +# -- Commands ----------------------------------------------------------------- + + +static func _cmd_create_primitive(parameters: Dictionary) -> Dictionary: + var err = MCPToolkitError.require(parameters, ["parent_path", "primitive"]) + if err != null: + return err + + var primitive := str(parameters.get("primitive", "")) + if primitive not in _VALID_PRIMITIVES: + return MCPToolkitError.fail("INVALID_PARAMS", + "invalid primitive '%s'; must be one of: %s" % [primitive, ", ".join(_VALID_PRIMITIVES)]) + + var parent_result = _resolve_parent(parameters) + if parent_result is Dictionary: + return parent_result + var parent: Node = parent_result + var root := Helpers.get_edited_root() + + var mesh_instance := MeshInstance3D.new() + mesh_instance.name = str(parameters.get("name", "MeshInstance3D")) + + # Create appropriate mesh type. + var mesh: Mesh + match primitive: + "box": + var box := BoxMesh.new() + if parameters.has("size"): + box.size = _vec3(parameters["size"]) + mesh = box + "sphere": + var sphere := SphereMesh.new() + if parameters.has("size"): + var s = parameters["size"] + if typeof(s) == TYPE_DICTIONARY: + if s.has("x"): + sphere.radius = float(s["x"]) / 2.0 + if s.has("y"): + sphere.height = float(s["y"]) + mesh = sphere + "cylinder": + var cyl := CylinderMesh.new() + if parameters.has("size"): + var s = parameters["size"] + if typeof(s) == TYPE_DICTIONARY: + if s.has("x"): + cyl.top_radius = float(s["x"]) / 2.0 + cyl.bottom_radius = float(s["x"]) / 2.0 + if s.has("y"): + cyl.height = float(s["y"]) + mesh = cyl + "capsule": + var cap := CapsuleMesh.new() + if parameters.has("size"): + var s = parameters["size"] + if typeof(s) == TYPE_DICTIONARY: + if s.has("x"): + cap.radius = float(s["x"]) / 2.0 + if s.has("y"): + cap.height = float(s["y"]) + mesh = cap + "plane": + var plane := PlaneMesh.new() + if parameters.has("size"): + var s = parameters["size"] + if typeof(s) == TYPE_DICTIONARY: + plane.size = Vector2( + float(s.get("x", 1.0)), + float(s.get("z", 1.0))) + mesh = plane + "prism": + var prism := PrismMesh.new() + if parameters.has("size"): + prism.size = _vec3(parameters["size"]) + mesh = prism + + mesh_instance.mesh = mesh + + # Material. + if parameters.has("material"): + var mat_data = parameters["material"] + if typeof(mat_data) == TYPE_DICTIONARY: + var mat := StandardMaterial3D.new() + if mat_data.has("albedo_color"): + mat.albedo_color = _color(mat_data["albedo_color"]) + if mat_data.has("metallic"): + mat.metallic = float(mat_data["metallic"]) + if mat_data.has("roughness"): + mat.roughness = float(mat_data["roughness"]) + mesh_instance.material_override = mat + + # Position. + if parameters.has("position"): + mesh_instance.position = _vec3(parameters["position"]) + + _add_node_undoable(parent, mesh_instance, root) + return MCPToolkitSuccess.ok({"status": "created", "path": _path_in_scene(root, mesh_instance)}) + + +static func _cmd_setup_environment(parameters: Dictionary) -> Dictionary: + var err = MCPToolkitError.require(parameters, ["parent_path"]) + if err != null: + return err + + var parent_result = _resolve_parent(parameters) + if parent_result is Dictionary: + return parent_result + var parent: Node = parent_result + var root := Helpers.get_edited_root() + + var env := Environment.new() + + # Sky. + var sky_data = parameters.get("sky", null) + var sky_material: ProceduralSkyMaterial + if sky_data != null and typeof(sky_data) == TYPE_DICTIONARY: + sky_material = ProceduralSkyMaterial.new() + if sky_data.has("sky_top_color"): + sky_material.sky_top_color = _color(sky_data["sky_top_color"]) + if sky_data.has("sky_horizon_color"): + sky_material.sky_horizon_color = _color(sky_data["sky_horizon_color"]) + if sky_data.has("ground_bottom_color"): + sky_material.ground_bottom_color = _color(sky_data["ground_bottom_color"]) + else: + sky_material = ProceduralSkyMaterial.new() + + var sky := Sky.new() + sky.sky_material = sky_material + env.sky = sky + env.background_mode = Environment.BG_SKY + + # Ambient light. + var ambient_data = parameters.get("ambient_light", null) + if ambient_data != null and typeof(ambient_data) == TYPE_DICTIONARY: + env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR + if ambient_data.has("color"): + env.ambient_light_color = _color(ambient_data["color"]) + if ambient_data.has("energy"): + env.ambient_light_energy = float(ambient_data["energy"]) + + # Tonemap. + var tonemap_str := str(parameters.get("tonemap", "")) + if not tonemap_str.is_empty(): + match tonemap_str: + "linear": + env.tonemap_mode = Environment.TONE_MAPPER_LINEAR + "reinhardt": + env.tonemap_mode = Environment.TONE_MAPPER_REINHARDT + "filmic": + env.tonemap_mode = Environment.TONE_MAPPER_FILMIC + "aces": + env.tonemap_mode = Environment.TONE_MAPPER_ACES + + # Fog. + var fog_data = parameters.get("fog", null) + if fog_data != null and typeof(fog_data) == TYPE_DICTIONARY: + env.fog_enabled = bool(fog_data.get("enabled", true)) + if fog_data.has("color"): + env.fog_light_color = _color(fog_data["color"]) + if fog_data.has("density"): + env.fog_density = float(fog_data["density"]) + + var world_env := WorldEnvironment.new() + world_env.name = str(parameters.get("name", "WorldEnvironment")) + world_env.environment = env + + _add_node_undoable(parent, world_env, root) + return MCPToolkitSuccess.ok({"status": "created", "path": _path_in_scene(root, world_env)}) + + +static func _cmd_create_light(parameters: Dictionary) -> Dictionary: + var err = MCPToolkitError.require(parameters, ["parent_path", "light_type"]) + if err != null: + return err + + var light_type := str(parameters.get("light_type", "")) + if light_type not in _VALID_LIGHT_TYPES: + return MCPToolkitError.fail("INVALID_PARAMS", + "invalid light_type '%s'; must be one of: %s" % [light_type, ", ".join(_VALID_LIGHT_TYPES)]) + + var parent_result = _resolve_parent(parameters) + if parent_result is Dictionary: + return parent_result + var parent: Node = parent_result + var root := Helpers.get_edited_root() + + var light: Light3D + match light_type: + "directional": + light = DirectionalLight3D.new() + light.name = str(parameters.get("name", "DirectionalLight3D")) + "omni": + light = OmniLight3D.new() + light.name = str(parameters.get("name", "OmniLight3D")) + "spot": + light = SpotLight3D.new() + light.name = str(parameters.get("name", "SpotLight3D")) + + if parameters.has("color"): + light.light_color = _color(parameters["color"]) + if parameters.has("energy"): + light.light_energy = float(parameters["energy"]) + if parameters.has("shadow"): + light.shadow_enabled = bool(parameters["shadow"]) + if parameters.has("position"): + light.position = _vec3(parameters["position"]) + if parameters.has("rotation"): + var rot = parameters["rotation"] + if typeof(rot) == TYPE_DICTIONARY: + light.rotation_degrees = _vec3(rot) + + _add_node_undoable(parent, light, root) + return MCPToolkitSuccess.ok({"status": "created", "path": _path_in_scene(root, light)}) + + +static func _cmd_create_camera(parameters: Dictionary) -> Dictionary: + var err = MCPToolkitError.require(parameters, ["parent_path"]) + if err != null: + return err + + var parent_result = _resolve_parent(parameters) + if parent_result is Dictionary: + return parent_result + var parent: Node = parent_result + var root := Helpers.get_edited_root() + + var camera := Camera3D.new() + camera.name = str(parameters.get("name", "Camera3D")) + + var projection_str := str(parameters.get("projection", "")) + if not projection_str.is_empty(): + match projection_str: + "perspective": + camera.projection = Camera3D.PROJECTION_PERSPECTIVE + "orthogonal": + camera.projection = Camera3D.PROJECTION_ORTHOGONAL + + if parameters.has("fov"): + camera.fov = float(parameters["fov"]) + if parameters.has("size"): + camera.size = float(parameters["size"]) + if parameters.has("position"): + camera.position = _vec3(parameters["position"]) + if parameters.has("rotation"): + var rot = parameters["rotation"] + if typeof(rot) == TYPE_DICTIONARY: + camera.rotation_degrees = _vec3(rot) + if parameters.has("current"): + camera.current = bool(parameters["current"]) + + _add_node_undoable(parent, camera, root) + return MCPToolkitSuccess.ok({"status": "created", "path": _path_in_scene(root, camera)}) diff --git a/addons/godot_mcp_toolkit/commands/3d_commands.gd.uid b/addons/godot_mcp_toolkit/commands/3d_commands.gd.uid new file mode 100644 index 0000000..7d07282 --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/3d_commands.gd.uid @@ -0,0 +1 @@ +uid://cdb5pscckdrgg diff --git a/addons/godot_mcp_toolkit/commands/animation_commands.gd b/addons/godot_mcp_toolkit/commands/animation_commands.gd new file mode 100644 index 0000000..17cf978 --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/animation_commands.gd @@ -0,0 +1,681 @@ +@tool +extends RefCounted +## animation.* command handlers — keyframe (add/remove) and get_keys on AnimationPlayer tracks. + +const Modules := preload("res://addons/godot_mcp_toolkit/core/modules.gd") +const Coerce = Modules.Coerce +const Untrusted = Modules.Untrusted +const Helpers = Modules.CommandHelpers + + +static func register(registry: MCPToolkitCommandRegistry, server: Node) -> void: + registry.add("animation.keyframe", func(parameters: Dictionary) -> Dictionary: + return _cmd_animation_keyframe(server, parameters) + , MCPToolkitCommandOptions.new()) + registry.add("animation.get_keys", func(parameters: Dictionary) -> Dictionary: + return _cmd_animation_get_keys(parameters) + , MCPToolkitCommandOptions.new().mark_read_only()) + registry.add("animationtree.edit", func(parameters: Dictionary) -> Dictionary: + return _cmd_animationtree_edit(server, parameters) + , MCPToolkitCommandOptions.new()) + registry.add("animationtree.list", func(parameters: Dictionary) -> Dictionary: + return _cmd_animationtree_list(parameters) + , MCPToolkitCommandOptions.new().mark_read_only()) + + +# -- Helpers ------------------------------------------------------------------ + + +static func _resolve_scene_node(node_path: String) -> Variant: + return Helpers.resolve_scene_node(node_path) + + +static func _resolve_animation( + player_path: String, animation_name: String, +) -> Dictionary: + var root := Helpers.get_edited_root() + if root == null: + return {"code": "NO_SCENE", "error": "no edited scene"} + if player_path.is_empty(): + return {"code": "INVALID_PARAMS", "error": "missing player_path"} + var node = _resolve_scene_node(player_path) + if node == null: + return {"code": "NOT_FOUND", + "error": "no node at player_path %s" % player_path} + if not (node is AnimationPlayer): + return {"code": "INVALID_CLASS", + "error": "node at %s is not an AnimationPlayer (got %s)" % [ + player_path, node.get_class()]} + var player := node as AnimationPlayer + if animation_name.is_empty(): + return {"code": "INVALID_PARAMS", "error": "missing animation_name"} + if not player.has_animation(animation_name): + # Auto-create: if "library/anim" format and the library exists, create the Animation. + var slash_pos := animation_name.find("/") + if slash_pos != -1: + var lib_name := animation_name.substr(0, slash_pos) + var anim_key := animation_name.substr(slash_pos + 1) + if player.has_animation_library(lib_name): + var lib := player.get_animation_library(lib_name) + var new_anim := Animation.new() + lib.add_animation(anim_key, new_anim) + return {"player": player, "anim": new_anim, "auto_created": true} + var available: Array = [] + for name_entry in player.get_animation_list(): + available.append(str(name_entry)) + if available.size() >= 10: + available.append("…") + break + return {"code": "NOT_FOUND", + "error": "no animation '%s' on player %s; available: %s" % [ + animation_name, player_path, ", ".join(available)]} + return {"player": player, "anim": player.get_animation(animation_name)} + + +static func _track_type_name(track_type: int) -> String: + match track_type: + Animation.TYPE_VALUE: return "value" + Animation.TYPE_POSITION_3D: return "position_3d" + Animation.TYPE_ROTATION_3D: return "rotation_3d" + Animation.TYPE_SCALE_3D: return "scale_3d" + Animation.TYPE_BLEND_SHAPE: return "blend_shape" + Animation.TYPE_METHOD: return "method" + Animation.TYPE_BEZIER: return "bezier" + Animation.TYPE_AUDIO: return "audio" + Animation.TYPE_ANIMATION: return "animation" + _: return "unknown(%d)" % track_type + + +# -- Commands ----------------------------------------------------------------- + + +static func _cmd_animation_keyframe( + server: Node, parameters: Dictionary, +) -> Dictionary: + var action := str(parameters.get("action", "")) + if not (action in ["add", "remove"]): + return MCPToolkitError.fail("INVALID_PARAMS", + "action must be 'add' or 'remove' (got '%s')" % action) + var player_path := str(parameters.get("player_path", "")) + player_path = Helpers.normalize_editor_path(player_path) + var animation_name := str(parameters.get("animation_name", "")) + var track_path := str(parameters.get("track_path", "")) + var time_raw = parameters.get("time", -1.0) + var time := float(time_raw) \ + if (typeof(time_raw) == TYPE_FLOAT or typeof(time_raw) == TYPE_INT) else -1.0 + if time < 0.0: + return MCPToolkitError.fail("INVALID_PARAMS", + "time must be >= 0 (got %f)" % time) + if track_path.is_empty(): + return MCPToolkitError.fail("INVALID_PARAMS", "missing track_path") + if action == "add": + if not track_path.contains(":"): + return MCPToolkitError.fail("INVALID_PARAMS", + "track_path must include a property (e.g. 'Sprite2D:position')") + var track_type_param := str(parameters.get("track_type", "")) + if not track_type_param.is_empty() and track_type_param != "value": + return MCPToolkitError.fail("INVALID_PARAMS", + "track_type='%s' not supported (only 'value' / default)" % track_type_param) + if not parameters.has("value"): + return MCPToolkitError.fail("INVALID_PARAMS", + "missing value (required for action='add')") + var raw_value = parameters.get("value", null) + var resolved := _resolve_animation(player_path, animation_name) + if resolved.has("error"): + return MCPToolkitError.fail(str(resolved["code"]), str(resolved["error"])) + var animation: Animation = resolved["anim"] + var player: AnimationPlayer = resolved["player"] + var missing := Coerce.check_resource_paths(raw_value) + if missing != "": + return MCPToolkitError.fail("LOAD_FAILED", + "failed to load resource at %s" % missing) + var coerced = Coerce.coerce_value(raw_value) + if typeof(coerced) == TYPE_DICTIONARY and (coerced as Dictionary).has("_coerce_error"): + return MCPToolkitError.fail("INVALID_PARAMS", str(coerced["_coerce_error"])) + var track_index := -1 + var track_path_node_path := NodePath(track_path) + for index in range(animation.get_track_count()): + if animation.track_get_path(index) == track_path_node_path: + track_index = index + break + if track_index == -1: + track_index = animation.add_track(Animation.TYPE_VALUE) + animation.track_set_path(track_index, track_path_node_path) + var existing_index := animation.track_find_key( + track_index, time, Animation.FIND_MODE_EXACT) + if existing_index != -1: + return MCPToolkitSuccess.ok({ + "status": "returned", + "player_path": player_path, + "animation_name": animation_name, + "track_path": track_path, + "track_idx": track_index, + "time": time, + "key_idx": existing_index, + "value": Coerce.serialize_value( + animation.track_get_key_value(track_index, existing_index)), + }) + animation.track_insert_key(track_index, time, coerced) + MCPToolkitUndoRedoAction.begin("animation.keyframe add %s @ %s" % [track_path, time], player) \ + .do_method(animation.track_insert_key.bind(track_index, time, coerced)) \ + .undo_method(server.undo_helpers._animation_remove_key_at.bind(animation, track_index, time)) \ + .undo_reference(animation) \ + .commit_recorded() + var new_index := animation.track_find_key( + track_index, time, Animation.FIND_MODE_EXACT) + # Persist external library to disk so runtime can find the animation. + var _slash := animation_name.find("/") + if _slash != -1: + var _lib_name := animation_name.substr(0, _slash) + var _lib := player.get_animation_library(_lib_name) + if _lib != null and not _lib.resource_path.is_empty(): + ResourceSaver.save(_lib) + return MCPToolkitSuccess.ok({ + "status": "created", + "player_path": player_path, + "animation_name": animation_name, + "track_path": track_path, + "track_idx": track_index, + "time": time, + "key_idx": new_index, + "value": Coerce.serialize_value(coerced), + }) + else: + var resolved := _resolve_animation(player_path, animation_name) + if resolved.has("error"): + return MCPToolkitError.fail(str(resolved["code"]), str(resolved["error"])) + var animation: Animation = resolved["anim"] + var track_index := -1 + var track_path_node_path := NodePath(track_path) + for index in range(animation.get_track_count()): + if animation.track_get_path(index) == track_path_node_path: + track_index = index + break + if track_index == -1: + return MCPToolkitError.fail("NOT_FOUND", + "no track '%s' on animation '%s'" % [track_path, animation_name]) + var key_index := animation.track_find_key( + track_index, time, Animation.FIND_MODE_EXACT) + if key_index == -1: + return MCPToolkitError.fail("NOT_FOUND", + "no key at time=%f on track '%s'" % [time, track_path]) + var captured_value = animation.track_get_key_value(track_index, key_index) + var serialised_value = Coerce.serialize_value(captured_value) + server.undo_helpers._animation_remove_key_at(animation, track_index, time) + MCPToolkitUndoRedoAction.begin("animation.keyframe remove %s @ %s" % [track_path, time], resolved["player"]) \ + .do_method(server.undo_helpers._animation_remove_key_at.bind(animation, track_index, time)) \ + .undo_method(server.undo_helpers._animation_insert_key_silent.bind(animation, track_index, time, captured_value)) \ + .undo_reference(animation) \ + .commit_recorded() + return MCPToolkitSuccess.ok({ + "player_path": player_path, + "animation_name": animation_name, + "track_path": track_path, + "time": time, + "removed_value": serialised_value, + }) + + +static func _cmd_animation_get_keys(parameters: Dictionary) -> Dictionary: + var player_path := str(parameters.get("player_path", "")) + player_path = Helpers.normalize_editor_path(player_path) + var animation_name := str(parameters.get("animation_name", "")) + var track_path := str(parameters.get("track_path", "")) + if track_path.is_empty(): + return MCPToolkitError.fail("INVALID_PARAMS", "missing track_path") + var resolved := _resolve_animation(player_path, animation_name) + if resolved.has("error"): + return MCPToolkitError.fail(str(resolved["code"]), str(resolved["error"])) + var animation: Animation = resolved["anim"] + var track_index := -1 + var track_path_node_path := NodePath(track_path) + for index in range(animation.get_track_count()): + if animation.track_get_path(index) == track_path_node_path: + track_index = index + break + if track_index == -1: + return MCPToolkitError.fail("NOT_FOUND", + "no track '%s' on animation '%s'" % [track_path, animation_name]) + var keys: Array = [] + for key_index in range(animation.track_get_key_count(track_index)): + keys.append({ + "time": animation.track_get_key_time(track_index, key_index), + "value": Coerce.serialize_value( + animation.track_get_key_value(track_index, key_index)), + "transition": animation.track_get_key_transition(track_index, key_index), + }) + return MCPToolkitSuccess.ok({ + "player_path": player_path, + "animation_name": animation_name, + "track_path": track_path, + "track_idx": track_index, + "track_type": _track_type_name(animation.track_get_type(track_index)), + "length": animation.length, + "keys": Untrusted.wrap( + "animation", "%s/%s" % [player_path, animation_name], + JSON.stringify(keys)), + }) + + +# -- AnimationTree editing ---------------------------------------------------- + + +static func _resolve_tree(node_path: String) -> Variant: + var root := Helpers.get_edited_root() + if root == null: + return MCPToolkitError.fail("NO_SCENE", "no edited scene") + if node_path.is_empty(): + return MCPToolkitError.fail("INVALID_PARAMS", "missing node_path") + var node = _resolve_scene_node(node_path) + if node == null: + return MCPToolkitError.fail("NOT_FOUND", "no node at %s" % node_path) + if not (node is AnimationTree): + return MCPToolkitError.fail("INVALID_CLASS", + "node at %s is not an AnimationTree (got %s)" % [node_path, node.get_class()]) + return node + + +static func _resolve_state_machine(tree: AnimationTree) -> Variant: + var tree_root = tree.tree_root + if tree_root == null: + return MCPToolkitError.fail("INVALID_STATE", "AnimationTree has no tree_root set") + if not (tree_root is AnimationNodeStateMachine): + return MCPToolkitError.fail("INVALID_STATE", + "tree_root is %s, not AnimationNodeStateMachine" % tree_root.get_class()) + return tree_root + + +static func _switch_mode_from_string(mode_str: String) -> int: + match mode_str: + "immediate": return 0 + "sync": return 1 + "at_end": return 2 + _: return 0 + + +static func _switch_mode_to_string(mode_int: int) -> String: + match mode_int: + 0: return "immediate" + 1: return "sync" + 2: return "at_end" + _: return "immediate" + + +static func _advance_mode_from_string(mode_str: String) -> int: + match mode_str: + "disabled": return 0 + "enabled": return 1 + "auto": return 2 + _: return 1 + + +static func _advance_mode_to_string(mode_int: int) -> String: + match mode_int: + 0: return "disabled" + 1: return "enabled" + 2: return "auto" + _: return "enabled" + + +static func _sm_summary(sm: AnimationNodeStateMachine) -> Dictionary: + # get_node_list() is a 4.5+ script API (engine: bound to get_node_list_as_typed_array + # in 4.5; absent on 4.2-4.4 → calling it errors and corrupts the return). Transitions + # are countable on every version (get_transition_count is 4.2+). + var summary := {"transitions_count": sm.get_transition_count()} + if sm.has_method(&"get_node_list"): + summary["nodes_count"] = sm.get_node_list().size() + else: + # On 4.2-4.4 node enumeration is unavailable, so omit nodes_count rather + # than report a fabricated 0 that would read as a failed add. + summary["note"] = "node enumeration unavailable on Godot 4.2-4.4 (get_node_list is 4.5+); nodes_count is omitted — verify the node via animationtree_list or the AnimationTree panel" + return summary + + +static func _cmd_animationtree_list(parameters: Dictionary) -> Dictionary: + var node_path := str(parameters.get("node_path", "")) + node_path = Helpers.normalize_editor_path(node_path) + var resolved = _resolve_tree(node_path) + if resolved is Dictionary: + return resolved + var tree: AnimationTree = resolved + return _at_list(tree) + + +static func _cmd_animationtree_edit( + server: Node, parameters: Dictionary, +) -> Dictionary: + var node_path := str(parameters.get("node_path", "")) + node_path = Helpers.normalize_editor_path(node_path) + var action := str(parameters.get("action", "")) + if action.is_empty(): + return MCPToolkitError.fail("INVALID_PARAMS", "missing action") + + var resolved = _resolve_tree(node_path) + if resolved is Dictionary: + return resolved + var tree: AnimationTree = resolved + + match action: + "set_root": + return _at_set_root(server, tree, node_path, parameters) + "add_node": + return _at_add_node(server, tree, node_path, parameters) + "remove_node": + return _at_remove_node(server, tree, node_path, parameters) + "add_transition": + return _at_add_transition(server, tree, node_path, parameters) + "remove_transition": + return _at_remove_transition(server, tree, node_path, parameters) + "set_property": + return _at_set_property(server, tree, node_path, parameters) + _: + return MCPToolkitError.fail("INVALID_PARAMS", + "unknown action '%s'; expected set_root|add_node|remove_node|add_transition|remove_transition|set_property" % action) + + +static func _at_set_root( + server: Node, tree: AnimationTree, node_path: String, + params: Dictionary, +) -> Dictionary: + var root_type := str(params.get("root_type", "AnimationNodeStateMachine")) + var new_root: AnimationNode = null + match root_type: + "AnimationNodeStateMachine": + new_root = AnimationNodeStateMachine.new() + "AnimationNodeBlendTree": + new_root = AnimationNodeBlendTree.new() + _: + return MCPToolkitError.fail("INVALID_PARAMS", + "root_type must be 'AnimationNodeStateMachine' or 'AnimationNodeBlendTree' (got '%s')" % root_type) + + var old_root = tree.tree_root + tree.tree_root = new_root + var action := MCPToolkitUndoRedoAction.begin("animationtree.edit set_root %s" % node_path, tree) \ + .do_property(tree, &"tree_root", new_root) \ + .undo_property(tree, &"tree_root", old_root) \ + .do_reference(new_root) + if old_root != null: + action.undo_reference(old_root) + action.commit_recorded() + + return MCPToolkitSuccess.ok({"root_type": root_type}) + + +static func _at_add_node( + server: Node, tree: AnimationTree, node_path: String, + params: Dictionary, +) -> Dictionary: + var sm_result = _resolve_state_machine(tree) + if sm_result is Dictionary: + return sm_result + var sm: AnimationNodeStateMachine = sm_result + + var node_name := str(params.get("node_name", "")) + if node_name.is_empty(): + return MCPToolkitError.fail("INVALID_PARAMS", "missing node_name") + var node_type := str(params.get("node_type", "AnimationNodeAnimation")) + + # Idempotent: if the node already exists, return it. + if sm.has_node(StringName(node_name)): + var existing = sm.get_node(StringName(node_name)) + var summary := _sm_summary(sm) + summary["status"] = "returned" + summary["node_name"] = node_name + summary["node_type"] = existing.get_class() + return MCPToolkitSuccess.ok(summary) + + # Instantiate the AnimationNode by class name. + if not ClassDB.class_exists(node_type): + return MCPToolkitError.fail("INVALID_CLASS", + "class '%s' does not exist" % node_type) + if not ClassDB.is_parent_class(node_type, "AnimationNode"): + return MCPToolkitError.fail("INVALID_CLASS", + "'%s' is not an AnimationNode subclass" % node_type) + var new_node: AnimationNode = ClassDB.instantiate(node_type) as AnimationNode + if new_node == null: + return MCPToolkitError.fail("INVALID_CLASS", + "could not instantiate '%s'" % node_type) + + # For AnimationNodeAnimation, set the animation property. + var anim_name := str(params.get("animation_name", "")) + if not anim_name.is_empty() and new_node is AnimationNodeAnimation: + (new_node as AnimationNodeAnimation).animation = StringName(anim_name) + + var pos := Vector2.ZERO + var pos_dict = params.get("position", {}) + if typeof(pos_dict) == TYPE_DICTIONARY: + pos = Vector2(float(pos_dict.get("x", 0)), float(pos_dict.get("y", 0))) + + sm.add_node(StringName(node_name), new_node, pos) + MCPToolkitUndoRedoAction.begin("animationtree.edit add_node %s/%s" % [node_path, node_name], tree) \ + .do_method(sm.add_node.bind(StringName(node_name), new_node, pos)) \ + .undo_method(sm.remove_node.bind(StringName(node_name))) \ + .do_reference(new_node) \ + .commit_recorded() + + var summary := _sm_summary(sm) + summary["status"] = "created" + summary["node_name"] = node_name + summary["node_type"] = node_type + return MCPToolkitSuccess.ok(summary) + + +static func _at_remove_node( + server: Node, tree: AnimationTree, node_path: String, + params: Dictionary, +) -> Dictionary: + var sm_result = _resolve_state_machine(tree) + if sm_result is Dictionary: + return sm_result + var sm: AnimationNodeStateMachine = sm_result + + var node_name := str(params.get("node_name", "")) + if node_name.is_empty(): + return MCPToolkitError.fail("INVALID_PARAMS", "missing node_name") + if not sm.has_node(StringName(node_name)): + return MCPToolkitError.fail("NOT_FOUND", + "no node '%s' in state machine" % node_name) + + var old_node: AnimationNode = sm.get_node(StringName(node_name)) + var old_pos: Vector2 = sm.get_node_position(StringName(node_name)) + sm.remove_node(StringName(node_name)) + MCPToolkitUndoRedoAction.begin("animationtree.edit remove_node %s/%s" % [node_path, node_name], tree) \ + .do_method(sm.remove_node.bind(StringName(node_name))) \ + .undo_method(sm.add_node.bind(StringName(node_name), old_node, old_pos)) \ + .undo_reference(old_node) \ + .commit_recorded() + + var summary := _sm_summary(sm) + summary["node_name"] = node_name + return MCPToolkitSuccess.ok(summary) + + +static func _at_add_transition( + server: Node, tree: AnimationTree, node_path: String, + params: Dictionary, +) -> Dictionary: + var sm_result = _resolve_state_machine(tree) + if sm_result is Dictionary: + return sm_result + var sm: AnimationNodeStateMachine = sm_result + + var from := str(params.get("from", "")) + var to := str(params.get("to", "")) + if from.is_empty() or to.is_empty(): + return MCPToolkitError.fail("INVALID_PARAMS", "missing from or to") + if not sm.has_node(StringName(from)): + return MCPToolkitError.fail("NOT_FOUND", "no node '%s' in state machine" % from) + if not sm.has_node(StringName(to)): + return MCPToolkitError.fail("NOT_FOUND", "no node '%s' in state machine" % to) + + # Check for existing identical transition (idempotent). + for i in range(sm.get_transition_count()): + if str(sm.get_transition_from(i)) == from and str(sm.get_transition_to(i)) == to: + var summary := _sm_summary(sm) + summary["status"] = "returned" + summary["from"] = from + summary["to"] = to + return MCPToolkitSuccess.ok(summary) + + var transition := AnimationNodeStateMachineTransition.new() + + var switch_mode_str := str(params.get("switch_mode", "")) + if not switch_mode_str.is_empty(): + transition.switch_mode = _switch_mode_from_string(switch_mode_str) + + var advance_condition_str := str(params.get("advance_condition", "")) + if not advance_condition_str.is_empty(): + transition.advance_condition = StringName(advance_condition_str) + + var advance_mode_str := str(params.get("advance_mode", "")) + if not advance_mode_str.is_empty(): + transition.advance_mode = _advance_mode_from_string(advance_mode_str) + + sm.add_transition(StringName(from), StringName(to), transition) + MCPToolkitUndoRedoAction.begin("animationtree.edit add_transition %s %s->%s" % [node_path, from, to], tree) \ + .do_method(sm.add_transition.bind(StringName(from), StringName(to), transition)) \ + .undo_method(server.undo_helpers._sm_remove_transition_by_endpoints.bind(sm, from, to)) \ + .do_reference(transition) \ + .commit_recorded() + + var summary := _sm_summary(sm) + summary["status"] = "created" + summary["from"] = from + summary["to"] = to + return MCPToolkitSuccess.ok(summary) + + +static func _at_remove_transition( + server: Node, tree: AnimationTree, node_path: String, + params: Dictionary, +) -> Dictionary: + var sm_result = _resolve_state_machine(tree) + if sm_result is Dictionary: + return sm_result + var sm: AnimationNodeStateMachine = sm_result + + var from := str(params.get("from", "")) + var to := str(params.get("to", "")) + if from.is_empty() or to.is_empty(): + return MCPToolkitError.fail("INVALID_PARAMS", "missing from or to") + + var found_idx := -1 + for i in range(sm.get_transition_count()): + if str(sm.get_transition_from(i)) == from and str(sm.get_transition_to(i)) == to: + found_idx = i + break + if found_idx == -1: + return MCPToolkitError.fail("NOT_FOUND", + "no transition from '%s' to '%s'" % [from, to]) + + var old_transition: AnimationNodeStateMachineTransition = sm.get_transition(found_idx) + sm.remove_transition_by_index(found_idx) + MCPToolkitUndoRedoAction.begin("animationtree.edit remove_transition %s %s->%s" % [node_path, from, to], tree) \ + .do_method(sm.remove_transition_by_index.bind(found_idx)) \ + .undo_method(sm.add_transition.bind(StringName(from), StringName(to), old_transition)) \ + .undo_reference(old_transition) \ + .commit_recorded() + + var summary := _sm_summary(sm) + summary["from"] = from + summary["to"] = to + return MCPToolkitSuccess.ok(summary) + + +static func _at_set_property( + server: Node, tree: AnimationTree, node_path: String, + params: Dictionary, +) -> Dictionary: + var sm_result = _resolve_state_machine(tree) + if sm_result is Dictionary: + return sm_result + var sm: AnimationNodeStateMachine = sm_result + + var target_node := str(params.get("target_node", "")) + if target_node.is_empty(): + return MCPToolkitError.fail("INVALID_PARAMS", "missing target_node") + var property := str(params.get("property", "")) + if property.is_empty(): + return MCPToolkitError.fail("INVALID_PARAMS", "missing property") + if not params.has("value"): + return MCPToolkitError.fail("INVALID_PARAMS", "missing value") + var value = params.get("value") + + if not sm.has_node(StringName(target_node)): + return MCPToolkitError.fail("NOT_FOUND", + "no node '%s' in state machine" % target_node) + var anim_node: AnimationNode = sm.get_node(StringName(target_node)) + + var old_value = anim_node.get(property) + var coerced = Coerce.coerce_value(value) + if typeof(coerced) == TYPE_DICTIONARY and (coerced as Dictionary).has("_coerce_error"): + return MCPToolkitError.fail("INVALID_PARAMS", str(coerced["_coerce_error"])) + + anim_node.set(property, coerced) + MCPToolkitUndoRedoAction.begin("animationtree.edit set_property %s/%s.%s" % [node_path, target_node, property], tree) \ + .do_property(anim_node, StringName(property), coerced) \ + .undo_property(anim_node, StringName(property), old_value) \ + .commit_recorded() + + var summary := _sm_summary(sm) + summary["target_node"] = target_node + summary["property"] = property + return MCPToolkitSuccess.ok(summary) + + +static func _at_list(tree: AnimationTree) -> Dictionary: + var tree_root = tree.tree_root + if tree_root == null: + return MCPToolkitSuccess.ok({"root_type": "none", "nodes": [], "transitions": []}) + + var root_type := tree_root.get_class() + if not (tree_root is AnimationNodeStateMachine): + return MCPToolkitSuccess.ok({"root_type": root_type, "nodes": [], "transitions": []}) + + var sm: AnimationNodeStateMachine = tree_root as AnimationNodeStateMachine + var nodes: Array = [] + # get_node_list() is a 4.5+ script API (absent on 4.2-4.4 → errors); guard it so list + # stays well-formed on older versions. Node enumeration is unavailable there (nodes:[]), + # but transitions below still enumerate (get_transition_* are 4.2+). + if sm.has_method(&"get_node_list"): + for sn_name in sm.get_node_list(): + var anim_node: AnimationNode = sm.get_node(sn_name) + # Robustness: skip an unreadable node rather than dereferencing null. + if anim_node == null: + continue + var pos: Vector2 = sm.get_node_position(sn_name) + var entry := { + "name": str(sn_name), + "type": anim_node.get_class(), + "position": {"x": pos.x, "y": pos.y}, + } + if anim_node is AnimationNodeAnimation: + entry["animation"] = str((anim_node as AnimationNodeAnimation).animation) + nodes.append(entry) + + var transitions: Array = [] + for i in range(sm.get_transition_count()): + var tr: AnimationNodeStateMachineTransition = sm.get_transition(i) + var t_entry := { + "from": str(sm.get_transition_from(i)), + "to": str(sm.get_transition_to(i)), + } + # Robustness: on some engine versions get_transition(i) can return null even + # for an in-range index (Godot 4.2 runtime divergence). Emit the + # endpoints we have and skip the transition-object fields rather than + # dereferencing null (which would yield a malformed return → INTERNAL). + if tr != null: + t_entry["switch_mode"] = _switch_mode_to_string(tr.switch_mode) + t_entry["advance_condition"] = str(tr.advance_condition) + t_entry["advance_mode"] = _advance_mode_to_string(tr.advance_mode) + transitions.append(t_entry) + + return MCPToolkitSuccess.ok({ + "root_type": root_type, + "nodes": Untrusted.wrap( + "animationtree", "nodes", + JSON.stringify(nodes)), + "transitions": Untrusted.wrap( + "animationtree", "transitions", + JSON.stringify(transitions)), + }) diff --git a/addons/godot_mcp_toolkit/commands/animation_commands.gd.uid b/addons/godot_mcp_toolkit/commands/animation_commands.gd.uid new file mode 100644 index 0000000..7d559e2 --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/animation_commands.gd.uid @@ -0,0 +1 @@ +uid://bweooujnmts8n diff --git a/addons/godot_mcp_toolkit/commands/asset_commands.gd b/addons/godot_mcp_toolkit/commands/asset_commands.gd new file mode 100644 index 0000000..ca9ffb3 --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/asset_commands.gd @@ -0,0 +1,354 @@ +@tool +extends RefCounted +## asset.* command handlers — list, get_dependencies, import binary assets. + +const Modules := preload("res://addons/godot_mcp_toolkit/core/modules.gd") +const FileGuard = Modules.FileGuard +const Helpers = Modules.CommandHelpers + +const IMPORT_ALLOWED_EXTENSIONS := [ + # Images + "png", "jpg", "jpeg", "webp", "svg", "bmp", "tga", "hdr", "exr", + # Audio + "wav", "ogg", "mp3", + # 3D models + "glb", "gltf", "obj", "fbx", "blend", "dae", + # Fonts + "ttf", "otf", "woff", "woff2", + # Translations + "po", "csv", + # Video + "ogv", +] +const IMPORT_MAX_FILE_BYTES := 50 * 1024 * 1024 +const IMPORT_MAX_BASE64_BYTES := 5 * 1024 * 1024 + + +static func register(registry: MCPToolkitCommandRegistry, _server: Node) -> void: + registry.add("asset.list", func(parameters: Dictionary) -> Dictionary: + return await _cmd_asset_list(parameters) + , MCPToolkitCommandOptions.new().mark_read_only().mark_scene_independent()) + registry.add("asset.get_dependencies", func(parameters: Dictionary) -> Dictionary: + return _cmd_asset_get_dependencies(parameters) + , MCPToolkitCommandOptions.new().mark_read_only().mark_scene_independent()) + registry.add("asset.import", func(parameters: Dictionary) -> Dictionary: + return await _cmd_asset_import(parameters) + , MCPToolkitCommandOptions.new().mark_scene_independent()) + + +# -- Helpers ------------------------------------------------------------------ + + +static func _walk_filesystem_directory( + directory: EditorFileSystemDirectory, + name_glob: String, + class_filter: String, + extension_filter: Array[String], + entries: Array, + max_count: int, +) -> int: + # Returns the FULL count of matching assets in this subtree (drives total_assets). + # Collecting into `entries` stops once it hits max_count, but counting + # continues past the cap — mirrors spatial_map's "keep counting total, stop + # collecting". The walk is over the in-memory EditorFileSystem cache. + var total := 0 + for index in range(directory.get_file_count()): + var file_path := directory.get_file_path(index) + var file_name := directory.get_file(index) + var file_type := directory.get_file_type(index) + if name_glob != "" and not file_name.matchn(name_glob): + continue + if extension_filter.size() > 0 \ + and not file_name.get_extension().to_lower() in extension_filter: + continue + if class_filter != "": + if file_type != class_filter \ + and not ClassDB.is_parent_class(file_type, class_filter): + continue + var mtime := FileAccess.get_modified_time(file_path) + # Ghost filter: EditorFileSystem may retain stale entries after + # deletion (mtime 0 = file gone from disk but index not flushed). + if mtime == 0 and not FileAccess.file_exists(file_path): + continue + total += 1 + if entries.size() < max_count: + entries.append({ + "path": file_path, + "class": file_type, + "size_bytes": null, + "modified_unix": mtime, + }) + for subdir_index in range(directory.get_subdir_count()): + total += _walk_filesystem_directory( + directory.get_subdir(subdir_index), + name_glob, class_filter, extension_filter, entries, max_count) + return total + + +# -- Commands ----------------------------------------------------------------- + + +static func _cmd_asset_list(parameters: Dictionary) -> Dictionary: + var path_prefix: String = str(parameters.get("path_prefix", "res://")) + var name_glob: String = str(parameters.get("name_glob", "")) + var class_filter: String = str(parameters.get("class_filter", "")) + var extension_filter: Array = parameters.get("extension_filter", []) + if typeof(extension_filter) != TYPE_ARRAY: + extension_filter = [] + var requested_limit: int = int(parameters.get("limit", 500)) + + var guard := FileGuard.resolve_safe(path_prefix) + if guard["error"] != null: + return MCPToolkitError.fail("PATH_DENIED", str(guard["reason"])) + # Validation split: a non-positive limit is a caller error (reject); an over-max + # limit is clamped and disclosed, so a caller that asks for "everything" gets the + # capped page plus limit_clamped rather than a hard failure. + if requested_limit < 1: + return MCPToolkitError.fail("INVALID_PARAMS", + "limit must be >= 1 (got %d)" % requested_limit) + var max_results: int = mini(requested_limit, 2000) + var limit_clamped := requested_limit > 2000 + var filesystem := EditorInterface.get_resource_filesystem() + if filesystem.is_scanning(): + return MCPToolkitError.fail("FILESYSTEM_NOT_READY", + "Godot's EditorFileSystem is mid-scan; call editor.wait_for_idle to poll until ready, or retry in 500-2000ms") + if class_filter != "": + var found_in_classdb := ClassDB.class_exists(class_filter) + var found_in_global := false + if not found_in_classdb: + for global_class_entry in ProjectSettings.get_global_class_list(): + if global_class_entry.get("class", "") == class_filter: + found_in_global = true + break + if not found_in_classdb and not found_in_global: + return MCPToolkitError.fail("INVALID_PARAMS", + "unknown class_filter '%s'; checked ClassDB (engine classes) and ProjectSettings.get_global_class_list() (GDScript class_name / C# [GlobalClass])" % class_filter) + + var normalized_extension_filter: Array[String] = [] + for extension in extension_filter: + var ext := str(extension).to_lower() + if ext.begins_with("."): + ext = ext.substr(1) + normalized_extension_filter.append(ext) + + var root_directory := filesystem.get_filesystem_path(path_prefix) + if root_directory == null: + # Intentional full scan(): update_file() is file-level only and does not + # index directories. This path triggers when a directory exists on disk + # but hasn't been indexed yet — rare, and scan() is the correct tool. + var abs_path := ProjectSettings.globalize_path(path_prefix) + if DirAccess.dir_exists_absolute(abs_path): + filesystem.scan() + # Wait up to 5s for scan to finish (matches editor_refresh timeout) + var scan_start := Time.get_ticks_msec() + while filesystem.is_scanning() and Time.get_ticks_msec() - scan_start < 5000: + await Engine.get_main_loop().create_timer(0.1).timeout + root_directory = filesystem.get_filesystem_path(path_prefix) + if root_directory == null: + return MCPToolkitError.fail("NOT_FOUND", + "no indexed directory at %s (path may exist on disk but not yet scanned — call editor.refresh or wait for is_scanning to clear)" % path_prefix, MCPToolkitError.HINT_FILE_PATH) + + var entries: Array = [] + var total_assets := _walk_filesystem_directory( + root_directory, name_glob, class_filter, + normalized_extension_filter, entries, max_results) + var has_more := total_assets > entries.size() + + # total_assets: the full match count (count-past-cap). Cursor-less — the depth-first + # FS walk is not linearly resumable, so the hint advises narrowing filters / raising limit. + var hint := "" + if has_more: + hint = "%d of %d assets returned (capped at limit) — narrow with path_prefix/name_glob/class_filter/extension_filter, or raise limit (<= 2000)." % [entries.size(), total_assets] + var extras: Dictionary = { + "path_prefix": path_prefix, + "filters_applied": { + "name_glob": name_glob, + "class_filter": class_filter, + "extension_filter": normalized_extension_filter, + }, + } + if limit_clamped: + extras["limit_clamped"] = true + var clamp_clause := "requested limit exceeds max 2000; clamped to 2000 — narrow filters to see the rest" + hint = clamp_clause if hint.is_empty() else "%s %s" % [hint, clamp_clause] + return MCPToolkitSuccess.ok(Modules.Pagination.build( + {"entries": entries}, "assets", total_assets, entries.size(), has_more, + "", 0, hint, extras)) + + +static func _cmd_asset_get_dependencies(parameters: Dictionary) -> Dictionary: + var err = MCPToolkitError.require(parameters, ["file_path"]) + if err != null: + return err + var file_path: String = str(parameters.get("file_path", "")) + var include_transitive: bool = bool(parameters.get("include_transitive", false)) + var max_results: int = int(parameters.get("limit", 200)) + + var guard := FileGuard.resolve_safe(file_path) + if guard["error"] != null: + return MCPToolkitError.fail("PATH_DENIED", str(guard["reason"])) + if not FileAccess.file_exists(file_path): + return MCPToolkitError.fail("NOT_FOUND", "no file at %s" % file_path, MCPToolkitError.HINT_FILE_PATH) + var filesystem := EditorInterface.get_resource_filesystem() + if filesystem.is_scanning(): + return MCPToolkitError.fail("FILESYSTEM_NOT_READY", + "Godot's EditorFileSystem is mid-scan; call editor.wait_for_idle to poll until ready, or retry in 500-2000ms") + + var dependencies: Array = [] + var visited: Dictionary = {} + var queue: Array[String] = [file_path] + visited[file_path] = true + var truncated := false + var total_dependencies := 0 + var depth := 0 + const MAX_TRANSITIVE_DEPTH := 50 + + # Count every unique dependency for total_dependencies, but stop COLLECTING rows + # once the cap is hit (count-past-cap, still bounded by MAX_TRANSITIVE_DEPTH). The first limit + # collected rows are byte-identical to before — only counting continues past it. + while queue.size() > 0: + var current := queue.pop_front() as String + var raw_dependencies := ResourceLoader.get_dependencies(current) + for raw_dependency in raw_dependencies: + var raw_string := String(raw_dependency) + var parts: PackedStringArray = raw_string.split("::") + var stripped := parts[0] + var dependency_class := "" + if stripped.begins_with("uid://"): + for part_index in range(1, parts.size()): + if parts[part_index].begins_with("res://"): + stripped = parts[part_index] + break + for part_index in range(parts.size()): + var segment := parts[part_index] + if segment != "" and not segment.begins_with("uid://") \ + and not segment.begins_with("res://"): + dependency_class = segment + break + if stripped.is_empty(): + continue + if visited.has(stripped): + continue + visited[stripped] = true + total_dependencies += 1 + if dependencies.size() < max_results: + dependencies.append({ + "path": stripped, + "raw_path": raw_string, + "class": dependency_class, + }) + else: + truncated = true + if include_transitive: + if FileAccess.file_exists(stripped): + queue.append(stripped) + depth += 1 + if depth > MAX_TRANSITIVE_DEPTH: + truncated = true + break + + var warnings: Array[String] = [] + if depth > MAX_TRANSITIVE_DEPTH: + warnings.append( + "transitive walk exceeded 50 levels — truncated to prevent unbounded recursion") + + var hint := "" + if truncated: + hint = "%d of %d dependencies returned (capped at limit) — raise limit (no cursor), or set include_transitive=false to reduce the set." % [dependencies.size(), total_dependencies] + return MCPToolkitSuccess.ok(Modules.Pagination.build( + {"path": file_path, "dependencies": dependencies}, + "dependencies", total_dependencies, dependencies.size(), truncated, + "", 0, hint, {"include_transitive": include_transitive, "warnings": warnings})) + + +static func _cmd_asset_import(parameters: Dictionary) -> Dictionary: + var source_path: String = str(parameters.get("source_path", "")) + var base64_data: String = str(parameters.get("base64_data", "")) + var dest_path: String = str(parameters.get("dest_path", "")) + var if_exists: String = str(parameters.get("if_exists", "return")) + var wait_for_scan_ms: int = int(parameters.get("wait_for_scan_ms", 5000)) + + # Extension allowlist — rich asset.import hint kept here; the shared helper + # re-checks generically for the other callers. + var extension := dest_path.get_extension().to_lower() + if extension not in IMPORT_ALLOWED_EXTENSIONS: + return MCPToolkitError.fail("INVALID_PATH", + "extension '%s' not in import allowlist: %s; use script.write for .gd/.cs, resource.write for .tres/.res, scene.create for .tscn" % [ + extension, ", ".join(PackedStringArray(IMPORT_ALLOWED_EXTENSIONS))]) + var has_source := source_path != "" + var has_base64 := base64_data != "" + if has_source and has_base64: + return MCPToolkitError.fail("INVALID_PARAMS", + "provide exactly one of source_path or base64_data, not both") + if not has_source and not has_base64: + return MCPToolkitError.fail("INVALID_PARAMS", + "provide source_path (absolute filesystem path) or base64_data (base64-encoded file content)") + + # Source-path mode guards + if has_source: + if source_path.begins_with("res://") or source_path.begins_with("user://"): + source_path = ProjectSettings.globalize_path(source_path) + if not FileAccess.file_exists(source_path): + return MCPToolkitError.fail("NOT_FOUND", + "source file not found: %s" % source_path, MCPToolkitError.HINT_FILE_PATH) + var source_file := FileAccess.open(source_path, FileAccess.READ) + if source_file == null: + return MCPToolkitError.fail("READ_FAILED", + "cannot read source file %s (err %d)" % [ + source_path, FileAccess.get_open_error()]) + var source_size := source_file.get_length() + source_file.close() + if source_size > IMPORT_MAX_FILE_BYTES: + return MCPToolkitError.fail("INVALID_PARAMS", + "source file %d bytes exceeds 50 MB limit" % source_size) + + var decoded_bytes := PackedByteArray() + if has_base64: + decoded_bytes = Marshalls.base64_to_raw(base64_data) + if decoded_bytes.is_empty() and base64_data.length() > 0: + return MCPToolkitError.fail("INVALID_PARAMS", + "base64_data is not valid base64") + if decoded_bytes.size() > IMPORT_MAX_BASE64_BYTES: + return MCPToolkitError.fail("INVALID_PARAMS", + "decoded base64 data %d bytes exceeds 5 MB limit" % decoded_bytes.size()) + + var bytes_to_write: PackedByteArray + var source_label: String + if has_source: + bytes_to_write = FileAccess.get_file_as_bytes(source_path) + if FileAccess.get_open_error() != OK: + return MCPToolkitError.fail("READ_FAILED", + "cannot read source file %s (err %d)" % [ + source_path, FileAccess.get_open_error()]) + source_label = "filesystem" + else: + bytes_to_write = decoded_bytes + source_label = "base64" + + # Shared write + import-settle bracket. The helper validates if_exists / + # wait_for_scan_ms, guards the path, handles if_exists / parent-dir, runs the + # write_fn below, and settles the import — identical to texture.generate / + # sound.generate. Only the raw-bytes write differs, so it lives in write_fn. + var write_fn := func(write_path: String) -> Dictionary: + var file_handle := FileAccess.open(write_path, FileAccess.WRITE) + if file_handle == null: + return MCPToolkitError.fail("WRITE_FAILED", + "cannot open %s for writing (err %d)" % [ + write_path, FileAccess.get_open_error()]) + file_handle.store_buffer(bytes_to_write) + file_handle.close() + return {} + + var result := await Helpers.write_asset_with_settle( + dest_path, PackedStringArray(IMPORT_ALLOWED_EXTENSIONS), + if_exists, wait_for_scan_ms, "asset.import", write_fn) + if not result.get("success", false): + return result + + # asset.import-specific payload fields. + if result.get("status") == "returned": + result["source"] = null + else: + result["source"] = source_label + result["size_bytes"] = bytes_to_write.size() + return result diff --git a/addons/godot_mcp_toolkit/commands/asset_commands.gd.uid b/addons/godot_mcp_toolkit/commands/asset_commands.gd.uid new file mode 100644 index 0000000..98a3efe --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/asset_commands.gd.uid @@ -0,0 +1 @@ +uid://dn0ec05sy4mgx diff --git a/addons/godot_mcp_toolkit/commands/audiobus_commands.gd b/addons/godot_mcp_toolkit/commands/audiobus_commands.gd new file mode 100644 index 0000000..e1339cc --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/audiobus_commands.gd @@ -0,0 +1,264 @@ +@tool +extends RefCounted +## audiobus.* command handlers — audio bus layout management. +## +## The published prefix is `audiobus.*` (audio-BUS routing: buses, sends, +## effects), deliberately distinct from `sound.*` (audio sample generation). +## A file noun of "audio" alone would blur the two — name by the bus concept. + +const Modules := preload("res://addons/godot_mcp_toolkit/core/modules.gd") +const FileGuard = Modules.FileGuard +const Helpers = Modules.CommandHelpers +const Untrusted = Modules.Untrusted + + +static func register(registry: MCPToolkitCommandRegistry, _server: Node) -> void: + registry.add("audiobus.edit", func(parameters: Dictionary) -> Dictionary: + return _cmd_audiobus_edit(parameters) + , MCPToolkitCommandOptions.new().mark_scene_independent()) + registry.add("audiobus.list", func(_parameters: Dictionary) -> Dictionary: + return _action_list() + , MCPToolkitCommandOptions.new().mark_scene_independent().mark_read_only()) + + +# -- Commands ----------------------------------------------------------------- + + +static func _cmd_audiobus_edit(parameters: Dictionary) -> Dictionary: + var err = MCPToolkitError.require(parameters, ["action"]) + if err != null: + return err + + var action := str(parameters.get("action", "")) + + match action: + "add_bus": + return _action_add_bus(parameters) + "remove_bus": + return _action_remove_bus(parameters) + "set_bus": + return _action_set_bus(parameters) + "add_effect": + return _action_add_effect(parameters) + "remove_effect": + return _action_remove_effect(parameters) + _: + return MCPToolkitError.fail("INVALID_PARAMS", + "Unknown action '%s'; expected add_bus, remove_bus, set_bus, add_effect, or remove_effect" % action) + + +# -- Actions ------------------------------------------------------------------ + + +static func _action_add_bus(parameters: Dictionary) -> Dictionary: + var bus_name := str(parameters.get("bus_name", "")) + if bus_name.is_empty(): + return MCPToolkitError.fail("INVALID_PARAMS", "bus_name is required for add_bus") + + # Check if bus already exists. + if _find_bus_index(bus_name) >= 0: + return MCPToolkitSuccess.ok({"status": "returned", "action": "add_bus", + "bus_name": bus_name, "bus_count": AudioServer.bus_count}) + + AudioServer.add_bus() + var new_idx := AudioServer.bus_count - 1 + AudioServer.set_bus_name(new_idx, bus_name) + + var send_to := str(parameters.get("send_to", "")) + if not send_to.is_empty(): + var send_idx := _find_bus_index(send_to) + if send_idx < 0: + # Remove the bus we just added — send target doesn't exist. + AudioServer.remove_bus(new_idx) + return MCPToolkitError.fail("NOT_FOUND", "send_to bus '%s' not found" % send_to) + AudioServer.set_bus_send(new_idx, send_to) + + _save_bus_layout() + return MCPToolkitSuccess.ok({"status": "created", "action": "add_bus", + "bus_name": bus_name, "bus_count": AudioServer.bus_count}) + + +static func _action_remove_bus(parameters: Dictionary) -> Dictionary: + var idx := _resolve_bus_index(parameters) + if idx == -2: + return MCPToolkitError.fail("INVALID_PARAMS", "bus_name or bus_index is required for remove_bus") + if idx < 0: + return MCPToolkitError.fail("NOT_FOUND", + "Bus '%s' not found" % str(parameters.get("bus_name", parameters.get("bus_index", "")))) + + if idx == 0: + return MCPToolkitError.fail("INVALID_PARAMS", "Cannot remove the Master bus") + + var removed_name := AudioServer.get_bus_name(idx) + AudioServer.remove_bus(idx) + _save_bus_layout() + return MCPToolkitSuccess.ok({"action": "remove_bus", + "bus_name": removed_name, "bus_count": AudioServer.bus_count}) + + +static func _action_set_bus(parameters: Dictionary) -> Dictionary: + var idx := _resolve_bus_index(parameters) + if idx == -2: + return MCPToolkitError.fail("INVALID_PARAMS", "bus_name or bus_index is required for set_bus") + if idx < 0: + return MCPToolkitError.fail("NOT_FOUND", + "Bus '%s' not found" % str(parameters.get("bus_name", parameters.get("bus_index", "")))) + + if parameters.has("volume_db"): + AudioServer.set_bus_volume_db(idx, float(parameters["volume_db"])) + if parameters.has("solo"): + AudioServer.set_bus_solo(idx, bool(parameters["solo"])) + if parameters.has("mute"): + AudioServer.set_bus_mute(idx, bool(parameters["mute"])) + if parameters.has("send_to"): + var send_to := str(parameters["send_to"]) + if not send_to.is_empty(): + var send_idx := _find_bus_index(send_to) + if send_idx < 0: + return MCPToolkitError.fail("NOT_FOUND", "send_to bus '%s' not found" % send_to) + AudioServer.set_bus_send(idx, send_to) + + _save_bus_layout() + return MCPToolkitSuccess.ok({"action": "set_bus", + "bus_name": AudioServer.get_bus_name(idx), "bus_count": AudioServer.bus_count}) + + +static func _action_add_effect(parameters: Dictionary) -> Dictionary: + var idx := _resolve_bus_index(parameters) + if idx == -2: + return MCPToolkitError.fail("INVALID_PARAMS", "bus_name or bus_index is required for add_effect") + if idx < 0: + return MCPToolkitError.fail("NOT_FOUND", + "Bus '%s' not found" % str(parameters.get("bus_name", parameters.get("bus_index", "")))) + + var effect_dict = parameters.get("effect", null) + if typeof(effect_dict) != TYPE_DICTIONARY: + return MCPToolkitError.fail("INVALID_PARAMS", "effect object is required for add_effect") + + var type_name := str(effect_dict.get("type", "")) + if type_name.is_empty(): + return MCPToolkitError.fail("INVALID_PARAMS", "effect.type is required") + + var effect := _create_effect(type_name) + if effect == null: + return MCPToolkitError.fail("INVALID_PARAMS", + "Unknown effect type '%s'; use full class name (AudioEffectReverb) or suffix (Reverb). Valid: Reverb, Delay, Chorus, Compressor, Distortion, EQ6/10/21, LowPassFilter, HighPassFilter, BandPassFilter, Limiter, Panner, Phaser, PitchShift, Amplify" % type_name) + + # Apply effect-specific properties if provided. + var properties = effect_dict.get("properties", null) + if properties != null and typeof(properties) == TYPE_DICTIONARY: + for key in properties: + if effect.has_method("set"): + effect.set(key, properties[key]) + + var at_index := int(effect_dict.get("index", -1)) + if at_index < 0: + at_index = AudioServer.get_bus_effect_count(idx) + AudioServer.add_bus_effect(idx, effect, at_index) + + var enabled = effect_dict.get("enabled", null) + if enabled != null: + AudioServer.set_bus_effect_enabled(idx, at_index, bool(enabled)) + + _save_bus_layout() + return MCPToolkitSuccess.ok({"action": "add_effect", + "bus_name": AudioServer.get_bus_name(idx), + "effect_type": effect.get_class(), + "effect_index": at_index, + "bus_count": AudioServer.bus_count}) + + +static func _action_remove_effect(parameters: Dictionary) -> Dictionary: + var idx := _resolve_bus_index(parameters) + if idx == -2: + return MCPToolkitError.fail("INVALID_PARAMS", "bus_name or bus_index is required for remove_effect") + if idx < 0: + return MCPToolkitError.fail("NOT_FOUND", + "Bus '%s' not found" % str(parameters.get("bus_name", parameters.get("bus_index", "")))) + + var effect_dict = parameters.get("effect", null) + if typeof(effect_dict) != TYPE_DICTIONARY: + return MCPToolkitError.fail("INVALID_PARAMS", "effect object with index is required for remove_effect") + + var effect_index = effect_dict.get("index", null) + if effect_index == null: + return MCPToolkitError.fail("INVALID_PARAMS", "effect.index is required for remove_effect") + + var ei := int(effect_index) + if ei < 0 or ei >= AudioServer.get_bus_effect_count(idx): + return MCPToolkitError.fail("INVALID_PARAMS", + "effect index %d out of range (bus has %d effects)" % [ei, AudioServer.get_bus_effect_count(idx)]) + + AudioServer.remove_bus_effect(idx, ei) + _save_bus_layout() + return MCPToolkitSuccess.ok({"action": "remove_effect", + "bus_name": AudioServer.get_bus_name(idx), + "effect_index": ei, + "bus_count": AudioServer.bus_count}) + + +static func _action_list() -> Dictionary: + var buses := [] + for i in range(AudioServer.bus_count): + var effects := [] + for j in range(AudioServer.get_bus_effect_count(i)): + var effect := AudioServer.get_bus_effect(i, j) + effects.append({ + "type": effect.get_class() if effect != null else "null", + "enabled": AudioServer.is_bus_effect_enabled(i, j), + "index": j, + }) + buses.append({ + "name": AudioServer.get_bus_name(i), + "index": i, + "send": AudioServer.get_bus_send(i), + "volume_db": AudioServer.get_bus_volume_db(i), + "solo": AudioServer.is_bus_solo(i), + "mute": AudioServer.is_bus_mute(i), + "effects": effects, + }) + return MCPToolkitSuccess.ok({ + "bus_count": AudioServer.bus_count, + "buses": Untrusted.wrap("audiobus", "project-audio", JSON.stringify(buses)), + }) + + +# -- Helpers ------------------------------------------------------------------ + + +static func _find_bus_index(bus_name: String) -> int: + for i in range(AudioServer.bus_count): + if AudioServer.get_bus_name(i) == bus_name: + return i + return -1 + + +## Resolve bus by name or index from parameters. +## Returns -2 if neither bus_name nor bus_index is provided. +## Returns -1 if not found. +static func _resolve_bus_index(parameters: Dictionary) -> int: + if parameters.has("bus_name") and not str(parameters["bus_name"]).is_empty(): + return _find_bus_index(str(parameters["bus_name"])) + if parameters.has("bus_index"): + var bi := int(parameters["bus_index"]) + if bi >= 0 and bi < AudioServer.bus_count: + return bi + return -1 + return -2 + + +static func _create_effect(type_name: String) -> AudioEffect: + # Accept both full class names ("AudioEffectReverb") and suffixes ("Reverb"). + var class_name_str := type_name + if not ClassDB.class_exists(class_name_str): + class_name_str = "AudioEffect" + type_name + if not ClassDB.class_exists(class_name_str): + return null + if not ClassDB.is_parent_class(class_name_str, "AudioEffect"): + return null + return ClassDB.instantiate(class_name_str) as AudioEffect + + +static func _save_bus_layout() -> void: + var layout := AudioServer.generate_bus_layout() + ResourceSaver.save(layout, "res://default_bus_layout.tres") diff --git a/addons/godot_mcp_toolkit/commands/audiobus_commands.gd.uid b/addons/godot_mcp_toolkit/commands/audiobus_commands.gd.uid new file mode 100644 index 0000000..16279f3 --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/audiobus_commands.gd.uid @@ -0,0 +1 @@ +uid://dadrnwydulo7x diff --git a/addons/godot_mcp_toolkit/commands/classdb_commands.gd b/addons/godot_mcp_toolkit/commands/classdb_commands.gd new file mode 100644 index 0000000..ae854e4 --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/classdb_commands.gd @@ -0,0 +1,449 @@ +@tool +extends RefCounted +## classdb.* command handlers — ClassDB and global class introspection. + +const Modules := preload("res://addons/godot_mcp_toolkit/core/modules.gd") + +const _VALID_SECTIONS: Array[String] = ["properties", "methods", "signals", "constants"] +const _MAX_ENTRIES_PER_SECTION := 200 +const _MAX_SEARCH_RESULTS := 200 + + +static func register(registry: MCPToolkitCommandRegistry, _server: Node) -> void: + registry.add("classdb.get_info", func(parameters: Dictionary) -> Dictionary: + return _cmd_classdb_get_info(parameters) + , MCPToolkitCommandOptions.new().mark_read_only().mark_scene_independent()) + registry.add("classdb.search", func(parameters: Dictionary) -> Dictionary: + return _cmd_classdb_search(parameters) + , MCPToolkitCommandOptions.new().mark_read_only().mark_scene_independent()) + + +# -- Command ----------------------------------------------------------------- + + +## Resolves the optional caller `limit` for a classdb page against [param hard_max]. +## A missing limit defaults to [param hard_max] (behavior-preserving); a value above it +## clamps to [param hard_max] and flags the clamp; a non-positive value is rejected. +## Returns {"limit": , "clamped": , "error": null} on success, or +## {"error": } when the limit is out of range. +static func _resolve_limit(parameters: Dictionary, hard_max: int) -> Dictionary: + if not parameters.has("limit"): + return {"limit": hard_max, "clamped": false, "error": null} + var requested := int(parameters.get("limit", hard_max)) + if requested < 1: + return {"error": MCPToolkitError.fail("INVALID_PARAMS", + "limit must be >= 1 (got %d)" % requested)} + return { + "limit": mini(requested, hard_max), + "clamped": requested > hard_max, + "error": null, + } + + +static func _cmd_classdb_get_info(parameters: Dictionary) -> Dictionary: + var cls: String = str(parameters.get("class_name", "")) + if cls == "": + return MCPToolkitError.fail("INVALID_PARAMS", "class_name is required") + + var include_inherited: bool = bool(parameters.get("include_inherited", false)) + var offset: int = maxi(int(parameters.get("offset", 0)), 0) + var limit_res := _resolve_limit(parameters, _MAX_ENTRIES_PER_SECTION) + if limit_res["error"] != null: + return limit_res["error"] + var section_cap: int = limit_res["limit"] + var sections: Array = parameters.get("sections", []) + if typeof(sections) != TYPE_ARRAY: + sections = [] + for s in sections: + if str(s) not in _VALID_SECTIONS: + return MCPToolkitError.fail("INVALID_PARAMS", + "invalid section '%s'; valid: %s" % [s, ", ".join(_VALID_SECTIONS)]) + + var want_all := sections.is_empty() + var want_properties := want_all or "properties" in sections + var want_methods := want_all or "methods" in sections + var want_signals := want_all or "signals" in sections + var want_constants := want_all or "constants" in sections + + # Two-stage resolution: ClassDB (native engine classes) first, + # then global class list (user-defined class_name classes). + var is_native := ClassDB.class_exists(cls) + var is_global := false + var global_entry: Dictionary = {} + var script_path := "" + + if not is_native: + for entry in ProjectSettings.get_global_class_list(): + if entry.get("class", "") == cls: + is_global = true + global_entry = entry + script_path = str(entry.get("path", "")) + break + + if not is_native and not is_global: + return MCPToolkitError.fail("UNKNOWN_CLASS", + "class not found in ClassDB or global class list: %s" % cls, MCPToolkitError.HINT_CLASS_NAME) + + var result: Dictionary = {"class_name": cls} + var has_more := false + + if is_native: + result["source"] = "native" + result["parent"] = ClassDB.get_parent_class(cls) + result["inheritance_chain"] = _build_chain(cls) + var no_inheritance := not include_inherited + if want_properties: + has_more = _add_property_section(result, ClassDB.class_get_property_list(cls, no_inheritance), offset, section_cap) or has_more + if want_methods: + has_more = _add_method_section(result, ClassDB.class_get_method_list(cls, no_inheritance), offset, section_cap) or has_more + if want_signals: + has_more = _add_signal_section(result, ClassDB.class_get_signal_list(cls, no_inheritance), offset, section_cap) or has_more + if want_constants: + has_more = _add_constants_native(result, cls, no_inheritance, offset, section_cap) or has_more + else: + result["source"] = "global" + result["script_path"] = script_path + result["parent"] = str(global_entry.get("base", "")) + result["inheritance_chain"] = _build_chain_global(cls, global_entry) + var script := ResourceLoader.load(script_path) as Script + if script == null: + return MCPToolkitError.fail("LOAD_FAILED", + "could not load script at %s for class %s" % [script_path, cls]) + if want_properties: + has_more = _add_property_section(result, script.get_script_property_list(), offset, section_cap) or has_more + if want_methods: + has_more = _add_method_section(result, script.get_script_method_list(), offset, section_cap) or has_more + if want_signals: + has_more = _add_signal_section(result, script.get_script_signal_list(), offset, section_cap) or has_more + if want_constants: + result["constants"] = {} + result["total_constants"] = 0 + result["enums"] = {} + result["total_enums"] = 0 + + # has_more is ALWAYS present (false on a complete read). On a single-section + # overflow the hint builder also adds a structured next_offset resume field. + result["has_more"] = has_more + if limit_res["clamped"]: + result["limit_clamped"] = true + if has_more: + var hint := _build_truncation_hint(result, offset) + if hint != "": + result["hint"] = hint + return MCPToolkitSuccess.ok(result) + + +static func _cmd_classdb_search(parameters: Dictionary) -> Dictionary: + var base_class: String = str(parameters.get("base_class", "")) + var pattern: String = str(parameters.get("pattern", "")) + var instantiable_only: bool = bool(parameters.get("instantiable_only", true)) + var include_global: bool = bool(parameters.get("include_global", true)) + var offset: int = maxi(int(parameters.get("offset", 0)), 0) + var limit_res := _resolve_limit(parameters, _MAX_SEARCH_RESULTS) + if limit_res["error"] != null: + return limit_res["error"] + var page_cap: int = limit_res["limit"] + + if base_class != "": + var base_exists := ClassDB.class_exists(base_class) + if not base_exists: + for entry in ProjectSettings.get_global_class_list(): + if entry.get("class", "") == base_class: + base_exists = true + break + if not base_exists: + return MCPToolkitError.fail("UNKNOWN_CLASS", + "base_class not found in ClassDB or global class list: %s" % base_class, MCPToolkitError.HINT_CLASS_NAME) + + var pattern_lower := pattern.to_lower() + var matches: Array = [] + var total := 0 + + # Native classes from ClassDB. + for cls in ClassDB.get_class_list(): + if base_class != "" and cls != base_class \ + and not ClassDB.is_parent_class(cls, base_class): + continue + if pattern_lower != "" and cls.to_lower().find(pattern_lower) == -1: + continue + if instantiable_only and not ClassDB.can_instantiate(cls): + continue + total += 1 + if total > offset and matches.size() < page_cap: + matches.append({ + "name": cls, + "parent": ClassDB.get_parent_class(cls), + "instantiable": ClassDB.can_instantiate(cls), + "source": "native", + }) + + # Global (user-defined class_name) classes. + if include_global: + for entry in ProjectSettings.get_global_class_list(): + var cls: String = str(entry.get("class", "")) + var base: String = str(entry.get("base", "")) + if cls == "": + continue + if base_class != "": + if cls != base_class and base != base_class: + var ancestor_match := false + var current := base + while current != "": + if current == base_class: + ancestor_match = true + break + if ClassDB.class_exists(current): + current = ClassDB.get_parent_class(current) + else: + var found := false + for g in ProjectSettings.get_global_class_list(): + if g.get("class", "") == current: + current = str(g.get("base", "")) + found = true + break + if not found: + break + if not ancestor_match: + continue + if pattern_lower != "" and cls.to_lower().find(pattern_lower) == -1: + continue + total += 1 + if total > offset and matches.size() < page_cap: + matches.append({ + "name": cls, + "parent": base, + "instantiable": true, + "source": "global", + "script_path": str(entry.get("path", "")), + }) + + # If neither filter was given, return only direct children of Object + # to avoid dumping 1000+ classes. + if base_class == "" and pattern == "": + var top_level: Array = [] + for m in matches: + if m["parent"] == "Object" or m["parent"] == "": + top_level.append(m) + top_level.sort_custom(func(a, b): return str(a["name"]) < str(b["name"])) + var top_page := Modules.Pagination.build( + {"classes": top_level}, "classes", top_level.size(), top_level.size(), + false, "", 0, "") + # Informational nudge — shown regardless of paging, so it is set after the + # envelope rather than through the builder's has_more-gated hint. + top_page["hint"] = "No filter provided; showing direct children of Object only. Use base_class or pattern to search." + return MCPToolkitSuccess.ok(top_page) + + matches.sort_custom(func(a, b): return str(a["name"]) < str(b["name"])) + # Canonical fields: total_classes + has_more ALWAYS present; on more, the builder + # adds the next_offset resume field beside the prose hint composed here. + var has_more := offset + matches.size() < total + var hint := "" + if has_more: + hint = "more classes remain — re-call classdb.search with offset = next_offset (%d) until has_more is false" % (offset + matches.size()) + var extras: Dictionary = {} + if limit_res["clamped"]: + extras["limit_clamped"] = true + var clamp_clause := "requested limit exceeds max %d — page with next_offset to see the rest" % _MAX_SEARCH_RESULTS + hint = clamp_clause if hint.is_empty() else "%s. %s" % [hint, clamp_clause] + return MCPToolkitSuccess.ok(Modules.Pagination.list_page( + {"classes": matches}, matches, offset, "classes", total, hint, extras)) + + +# -- Inheritance chain ------------------------------------------------------- + + +static func _build_chain(cls: String) -> Array[String]: + var chain: Array[String] = [] + var current := cls + while current != "": + chain.append(current) + current = ClassDB.get_parent_class(current) + return chain + + +static func _build_chain_global(cls: String, entry: Dictionary) -> Array[String]: + var chain: Array[String] = [cls] + var current: String = str(entry.get("base", "")) + while current != "": + chain.append(current) + if ClassDB.class_exists(current): + current = ClassDB.get_parent_class(current) + else: + var found := false + for g in ProjectSettings.get_global_class_list(): + if g.get("class", "") == current: + current = str(g.get("base", "")) + found = true + break + if not found: + break + return chain + + +# -- Section builders -------------------------------------------------------- +# Each builder paginates a pre-fetched metadata list (`raw`) and writes its +# section + "total_
" into `result`. The caller supplies `raw` from +# whichever source applies — ClassDB.class_get_*_list() for native classes, +# script.get_script_*_list() for global (class_name) classes — so the builders +# depend only on the rows, not on how they were fetched. + + +static func _add_property_section(result: Dictionary, raw: Array, offset: int, cap: int) -> bool: + var total := raw.size() + result["total_properties"] = total + var out: Array = [] + var skipped := 0 + for p in raw: + if skipped < offset: + skipped += 1 + continue + if out.size() >= cap: + break + out.append({ + "name": p.get("name", ""), + "type": p.get("type", 0), + "hint": p.get("hint", 0), + "hint_string": p.get("hint_string", ""), + }) + result["properties"] = out + return offset + out.size() < total + + +static func _add_method_section(result: Dictionary, raw: Array, offset: int, cap: int) -> bool: + var total := raw.size() + result["total_methods"] = total + var out: Array = [] + var skipped := 0 + for m in raw: + if skipped < offset: + skipped += 1 + continue + if out.size() >= cap: + break + out.append({ + "name": m.get("name", ""), + "args": _format_args(m.get("args", [])), + "return_type": _resolve_type(m.get("return", {}), true), + "flags": m.get("flags", 0), + }) + result["methods"] = out + return offset + out.size() < total + + +static func _add_signal_section(result: Dictionary, raw: Array, offset: int, cap: int) -> bool: + var total := raw.size() + result["total_signals"] = total + var out: Array = [] + var skipped := 0 + for s in raw: + if skipped < offset: + skipped += 1 + continue + if out.size() >= cap: + break + out.append({ + "name": s.get("name", ""), + "args": _format_args(s.get("args", [])), + }) + result["signals"] = out + return offset + out.size() < total + + +# -- Native-only sections (constants + enums; no script twin) ----------------- + + +static func _add_constants_native( + result: Dictionary, cls: String, no_inheritance: bool, offset: int, cap: int, +) -> bool: + var const_list := ClassDB.class_get_integer_constant_list(cls, no_inheritance) + var const_total := const_list.size() + result["total_constants"] = const_total + var constants: Dictionary = {} + var skipped := 0 + for c_name in const_list: + if skipped < offset: + skipped += 1 + continue + if constants.size() >= cap: + break + constants[c_name] = ClassDB.class_get_integer_constant(cls, c_name) + result["constants"] = constants + + var enum_list := ClassDB.class_get_enum_list(cls, no_inheritance) + var enum_total := enum_list.size() + result["total_enums"] = enum_total + var enums: Dictionary = {} + var enum_skipped := 0 + for enum_name in enum_list: + if enum_skipped < offset: + enum_skipped += 1 + continue + if enums.size() >= cap: + break + var members: Array[String] = [] + for ec in ClassDB.class_get_enum_constants(cls, enum_name, no_inheritance): + members.append(ec) + enums[enum_name] = members + result["enums"] = enums + return offset + constants.size() < const_total or offset + enums.size() < enum_total + + +# -- Truncation hint builder -------------------------------------------------- + + +static func _build_truncation_hint(result: Dictionary, offset: int) -> String: + var parts: Array[String] = [] + var single_section := "" + var single_next_offset := 0 + for section in ["properties", "methods", "signals", "constants", "enums"]: + var total_key: String = "total_" + section + if not result.has(total_key): + continue + var total: int = result[total_key] + var returned := 0 + if result.has(section): + var val = result[section] + if val is Array: + returned = val.size() + elif val is Dictionary: + returned = val.size() + var remaining := total - offset - returned + if remaining > 0: + parts.append("%d %s" % [remaining, section]) + single_section = section + single_next_offset = offset + returned + if parts.is_empty(): + return "" + if parts.size() == 1: + # Surface the resume field as a structured next_offset alongside the prose loop hint. + result["next_offset"] = single_next_offset + return "more %s remain — re-call classdb.get_info with offset = next_offset (%d) until has_more is false" % [ + single_section, single_next_offset] + return "%s has more entries — page each section with sections + offset until has_more is false." % " + ".join(parts) + + +# -- Shared formatting helpers ----------------------------------------------- + + +static func _format_args(args: Array) -> Array: + var out: Array = [] + for a in args: + out.append({ + "name": a.get("name", ""), + "type": _resolve_type(a, false), + }) + return out + + +static func _resolve_type(info: Variant, is_return: bool) -> String: + if typeof(info) != TYPE_DICTIONARY: + return "void" if is_return else "Variant" + var type_id: int = info.get("type", 0) + if type_id == TYPE_OBJECT: + var cls_name: String = str(info.get("class_name", "")) + if cls_name != "": + return cls_name + return "Object" + if type_id == TYPE_NIL: + return "void" if is_return else "Variant" + return type_string(type_id) diff --git a/addons/godot_mcp_toolkit/commands/classdb_commands.gd.uid b/addons/godot_mcp_toolkit/commands/classdb_commands.gd.uid new file mode 100644 index 0000000..a5a12d1 --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/classdb_commands.gd.uid @@ -0,0 +1 @@ +uid://cnknbgnobxnri diff --git a/addons/godot_mcp_toolkit/commands/debug_commands.gd b/addons/godot_mcp_toolkit/commands/debug_commands.gd new file mode 100644 index 0000000..4535d25 --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/debug_commands.gd @@ -0,0 +1,184 @@ +@tool +extends RefCounted +## debug.* command handlers — breakpoint management + debug state. + +const Modules := preload("res://addons/godot_mcp_toolkit/core/modules.gd") +const FileGuard = Modules.FileGuard + + +static func register(registry: MCPToolkitCommandRegistry, debug_bridge: RefCounted) -> void: + registry.add("debug.state", func(_params: Dictionary) -> Dictionary: + return _cmd_debug_state(debug_bridge) + , MCPToolkitCommandOptions.new().mark_read_only().mark_scene_independent()) + registry.add("debug.list_breakpoints", func(_params: Dictionary) -> Dictionary: + return _cmd_debug_list_breakpoints() + , MCPToolkitCommandOptions.new().mark_read_only().mark_scene_independent()) + registry.add("debug.set_breakpoint", func(params: Dictionary) -> Dictionary: + return _cmd_debug_set_breakpoint(params) + , MCPToolkitCommandOptions.new().mark_scene_independent()) + registry.add("debug.continue", func(_params: Dictionary) -> Dictionary: + return _cmd_debug_continue(debug_bridge) + , MCPToolkitCommandOptions.new().mark_scene_independent()) + + +# -- Commands ----------------------------------------------------------------- + + +static func _cmd_debug_state(debug_bridge: RefCounted) -> Dictionary: + var state := debug_bridge.get_debug_state() as Dictionary + return MCPToolkitSuccess.ok(state) + + +static func _cmd_debug_list_breakpoints() -> Dictionary: + var script_editor := EditorInterface.get_script_editor() + if script_editor == null: + return MCPToolkitSuccess.ok({"breakpoints": [], "count": 0, + "note": "GDScript breakpoints only"}) + + var open_scripts := script_editor.get_open_scripts() + var breakpoints := [] + + # Save the current script to restore after iteration. + var original_script = script_editor.get_current_script() + + for script in open_scripts: + if not (script is Script): + continue + if not script.resource_path.ends_with(".gd"): + continue # GDScript only — C# breakpoints are IDE-managed. + + # Switch to this script's tab to access its CodeEdit. + EditorInterface.edit_script(script, -1, 0, false) + + var editor := script_editor.get_current_editor() + if editor == null: + continue + var code_edit := editor.get_base_editor() as CodeEdit + if code_edit == null: + continue + + for line_idx in code_edit.get_line_count(): + if code_edit.is_line_breakpointed(line_idx): + breakpoints.append({ + "file_path": script.resource_path, + "line": line_idx + 1, # 1-based for the API consumer. + }) + + # Restore the original script tab. + if original_script != null and original_script is Script: + EditorInterface.edit_script(original_script, -1, 0, false) + + return MCPToolkitSuccess.ok({"breakpoints": breakpoints, "count": breakpoints.size(), + "note": "GDScript breakpoints only"}) + + +static func _cmd_debug_set_breakpoint(params: Dictionary) -> Dictionary: + var file_path := str(params.get("file_path", "")) + var line: int = int(params.get("line", 0)) + var enabled: bool = true + if params.has("enabled"): + enabled = bool(params.get("enabled")) + + if file_path.is_empty(): + return MCPToolkitError.fail("INVALID_PARAMS", "file_path is required") + if line < 1: + return MCPToolkitError.fail("INVALID_PARAMS", "line must be >= 1") + + # Reject non-GDScript files. + if file_path.ends_with(".cs"): + return MCPToolkitError.fail("UNSUPPORTED_FILE_TYPE", + "Breakpoint management supports GDScript (.gd) files only. " + + "C# breakpoints should be set in your IDE (VS Code, Rider).") + if not file_path.ends_with(".gd"): + return MCPToolkitError.fail("UNSUPPORTED_FILE_TYPE", + "Breakpoint management supports GDScript (.gd) files only.") + + # I4: FileGuard path validation. + if not file_path.begins_with("res://"): + return MCPToolkitError.fail("INVALID_PATH", "file_path must start with res://") + var guard := FileGuard.resolve_safe(file_path) + if guard["error"] != null: + return MCPToolkitError.fail("PATH_DENIED", str(guard["reason"])) + + if not FileAccess.file_exists(file_path): + return MCPToolkitError.fail("NOT_FOUND", + "no file at %s" % file_path, MCPToolkitError.HINT_FILE_PATH) + + # Load and open the script in the editor. + var script: Script = ResourceLoader.load(file_path) as Script + if script == null: + return MCPToolkitError.fail("LOAD_FAILED", + "could not load %s as Script" % file_path) + + # A breakpoint lives on the built-in script editor's CodeEdit. With an external + # editor active, edit_script opens the file in that editor instead, the built-in + # tab never becomes current, and the identity-bind below would fail with an opaque + # INTERNAL. Detect the setting up front and return a distinct, steering error so + # the caller can ask the user to switch back to Godot's built-in editor. + var editor_settings := EditorInterface.get_editor_settings() + if editor_settings.has_setting("text_editor/external/use_external_editor") \ + and bool(editor_settings.get_setting("text_editor/external/use_external_editor")): + return MCPToolkitError.fail("EXTERNAL_EDITOR_ACTIVE", + "breakpoints require Godot's built-in script editor, but this project is set to " + + "an external editor", + "Ask the user to disable Editor Settings → Text Editor → External → 'Use External " + + "Editor', then retry. Breakpoints are set on the built-in CodeEdit, which an " + + "external editor bypasses.") + + EditorInterface.edit_script(script, line) + + var script_editor := EditorInterface.get_script_editor() + if script_editor == null: + return MCPToolkitError.fail("INTERNAL", "ScriptEditor not available") + + # Bind by IDENTITY, not by "current editor". edit_script() should foreground + # the target's tab, but stale/phantom tabs (deleted-file scripts stay open on + # 4.2-4.4, which has no auto-close) can leave a different tab current — setting + # the breakpoint there would land it on the wrong file while echoing the + # requested path. Confirm the current editor actually holds file_path before + # touching any line. + var current_script := script_editor.get_current_script() + if current_script == null or current_script.resource_path != file_path: + return MCPToolkitError.fail("INTERNAL", + "could not open %s in the script editor to set the breakpoint" % file_path) + + var editor := script_editor.get_current_editor() + if editor == null: + return MCPToolkitError.fail("INTERNAL", + "no current script editor after edit_script") + var code_edit := editor.get_base_editor() as CodeEdit + if code_edit == null: + return MCPToolkitError.fail("INTERNAL", "CodeEdit not available") + + # Validate line number against file length. + var line_count := code_edit.get_line_count() + if line > line_count: + return MCPToolkitError.fail("INVALID_PARAMS", + "line %d exceeds file length (%d lines)" % [line, line_count]) + + # Set or clear the breakpoint (0-based internally), then verify it took. + code_edit.set_line_as_breakpoint(line - 1, enabled) + if code_edit.is_line_breakpointed(line - 1) != enabled: + return MCPToolkitError.fail("INTERNAL", + "breakpoint state did not apply on %s:%d" % [file_path, line]) + + # Echo the VERIFIED path (the one the breakpoint actually landed on), never the + # unchecked request. + return MCPToolkitSuccess.ok( + {"file_path": current_script.resource_path, "line": line, "enabled": enabled}) + + +static func _cmd_debug_continue(debug_bridge: RefCounted) -> Dictionary: + var result := debug_bridge.try_continue() as Dictionary + if result.has("error"): + var code: String = result["error"] + match code: + "GAME_NOT_RUNNING": + return MCPToolkitError.fail("GAME_NOT_RUNNING", + "no active debug session — start a game with game.start first") + "NOT_BREAKED": + return MCPToolkitError.fail("NOT_BREAKED", + "debug session is active but not paused at a breakpoint") + _: + return MCPToolkitError.fail("INTERNAL", str(result)) + return MCPToolkitSuccess.ok(result) diff --git a/addons/godot_mcp_toolkit/commands/debug_commands.gd.uid b/addons/godot_mcp_toolkit/commands/debug_commands.gd.uid new file mode 100644 index 0000000..ae0c5a4 --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/debug_commands.gd.uid @@ -0,0 +1 @@ +uid://bygj4dlcvs1if diff --git a/addons/godot_mcp_toolkit/commands/editor/editor_commands.gd b/addons/godot_mcp_toolkit/commands/editor/editor_commands.gd new file mode 100644 index 0000000..f09b0e7 --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/editor/editor_commands.gd @@ -0,0 +1,50 @@ +@tool +extends RefCounted +## editor.* / execute.* command registration orchestrator — wires every editor.* +## and execute.* tool onto the registry and delegates each handler to its command +## child (log-reader, rescan, screenshot, execute). Holds no subdomain logic; the +## only handler it keeps local is the two-line editor.set_lsp_status server push. + +const _LogReader := preload("res://addons/godot_mcp_toolkit/commands/editor/editor_log_reader.gd") +const _Rescan := preload("res://addons/godot_mcp_toolkit/commands/editor/editor_rescan.gd") +const _Screenshot := preload("res://addons/godot_mcp_toolkit/commands/editor/editor_screenshot.gd") +const _Execute := preload("res://addons/godot_mcp_toolkit/commands/editor/editor_execute.gd") + + +static func register(registry: MCPToolkitCommandRegistry, server: Node) -> void: + registry.add("editor.save_scene", func(parameters: Dictionary) -> Dictionary: + # Route through the public safety class: C2 scan-idle guard + the C1 + # _in_dispatch flag around the synchronous save (see + # mcp_toolkit_safe_scene_ops.gd). NEVER call EditorInterface.save_scene[_as] + # directly — it can re-enter Main::iteration() mid-dispatch and crash/wedge. + return await MCPToolkitSafeSceneOps.save_scene(str(parameters.get("file_path", ""))) + , MCPToolkitCommandOptions.new()) + registry.add("editor.screenshot", func(parameters: Dictionary) -> Dictionary: + return await _Screenshot.cmd_screenshot(parameters) + , MCPToolkitCommandOptions.new().mark_read_only()) + registry.add("editor.refresh", func(parameters: Dictionary) -> Dictionary: + return await _Rescan.cmd_refresh(parameters) + , MCPToolkitCommandOptions.new().mark_scene_independent()) + registry.add("editor.get_console", func(parameters: Dictionary) -> Dictionary: + return _LogReader.cmd_get_console(server, parameters) + , MCPToolkitCommandOptions.new().mark_read_only().mark_scene_independent()) + registry.add("editor.wait_for_idle", func(parameters: Dictionary) -> Dictionary: + return await _Rescan.cmd_wait_for_idle(parameters) + , MCPToolkitCommandOptions.new().mark_read_only().mark_scene_independent()) + registry.add("execute.code", func(parameters: Dictionary) -> Dictionary: + return _Execute.cmd_execute_code(parameters) + , MCPToolkitCommandOptions.new()) + registry.add("editor.set_lsp_status", func(parameters: Dictionary) -> Dictionary: + return _cmd_set_lsp_status(server, parameters) + , MCPToolkitCommandOptions.new().mark_read_only().mark_scene_independent()) + + +# -- Commands ----------------------------------------------------------------- + + +## editor.set_lsp_status — the MCP server pushes its authoritative GDScript LSP +## verdict here (the editor can't read its own LSP bind status). Stored on the +## server for the dock to display. Internal command — not an MCP tool. +static func _cmd_set_lsp_status(server: Node, parameters: Dictionary) -> Dictionary: + server.set_reported_lsp_status(parameters) + return MCPToolkitSuccess.ok({"reported": true}) diff --git a/addons/godot_mcp_toolkit/commands/editor/editor_commands.gd.uid b/addons/godot_mcp_toolkit/commands/editor/editor_commands.gd.uid new file mode 100644 index 0000000..74f8dda --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/editor/editor_commands.gd.uid @@ -0,0 +1 @@ +uid://boylsra28pw2v diff --git a/addons/godot_mcp_toolkit/commands/editor/editor_execute.gd b/addons/godot_mcp_toolkit/commands/editor/editor_execute.gd new file mode 100644 index 0000000..7fce5f7 --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/editor/editor_execute.gd @@ -0,0 +1,69 @@ +@tool +extends RefCounted +## execute.code: evaluate a single GDScript expression (not statements) via the +## engine Expression class, against an editor-side scope node (the edited scene +## root, an explicit scope_path under it, or the editor base control as a +## fallback). Guards statement keywords, and on an execution failure appends a +## recovery hint (inaccessible global, load(), or chained-property access) built +## by the shared ExecuteHints helper — the same one the runtime handler uses. +## +## Stateless — the handler takes (parameters) and returns the response Dictionary. +## Reaches the Expression evaluator / EditorInterface directly; scope-path +## normalization and result serialization are reached via the Modules aliases. +## An extracted submodule of the editor-command group, reached via a `preload` alias. + +const Modules := preload("res://addons/godot_mcp_toolkit/core/modules.gd") +const Helpers = Modules.CommandHelpers +const Coerce = Modules.Coerce +const ExecuteHints = Modules.ExecuteHints + + +# -- Commands ----------------------------------------------------------------- + + +static func cmd_execute_code(parameters: Dictionary) -> Dictionary: + var code := str(parameters.get("code", "")) + if code.is_empty(): + return MCPToolkitError.fail("INVALID_PARAMS", "missing code") + + # Statement keyword guard (same as runtime handler). + var trimmed := code.strip_edges() + for kw in ["var", "return", "func", "if", "for", "while", "class", "const", "match"]: + if trimmed == kw or trimmed.begins_with(kw + " ") or trimmed.begins_with(kw + "\t") or trimmed.begins_with(kw + "\n"): + return MCPToolkitError.fail("PARSE_ERROR", + "execute_code only supports expressions, not statements. '%s' is a statement keyword. " % kw + + "Use method calls, property access, or arithmetic instead.") + + # Resolve scope node. + var scope_node: Node = null + var scope_path := str(parameters.get("scope_path", "")) + if scope_path.is_empty(): + var edited := EditorInterface.get_edited_scene_root() + if edited != null: + scope_node = edited + else: + # Fallback to editor base control so expressions still have a Node scope + scope_node = EditorInterface.get_base_control() + else: + var edited := EditorInterface.get_edited_scene_root() + if edited == null: + return MCPToolkitError.fail("NO_SCENE", "No scene open — cannot resolve scope_path") + scope_path = Helpers.normalize_editor_path(scope_path) + scope_node = edited.get_node_or_null(NodePath(scope_path)) + if scope_node == null: + return MCPToolkitError.fail("NOT_FOUND", "scope node not found: " + scope_path) + + var expr := Expression.new() + var parse_err := expr.parse(code, PackedStringArray()) + if parse_err != OK: + # Frame the raw Expression parser message so the failure is actionable: + # execute_code parses a single GDScript EXPRESSION (no statements, + # assignments, or blocks), which is the usual cause of a parse failure. + return MCPToolkitError.fail("PARSE_ERROR", + "could not parse the expression: %s. execute_code evaluates a single GDScript expression — no assignments, statements, or multi-line blocks. Use a method call, property access, or arithmetic." % expr.get_error_text()) + var result = expr.execute([], scope_node, false) + if expr.has_execute_failed(): + var err_text := expr.get_error_text() + err_text += ExecuteHints.build_hint(err_text, code) + return MCPToolkitError.fail("EXECUTE_FAILED", err_text) + return MCPToolkitSuccess.ok({"result": Coerce.serialize_value(result)}) diff --git a/addons/godot_mcp_toolkit/commands/editor/editor_execute.gd.uid b/addons/godot_mcp_toolkit/commands/editor/editor_execute.gd.uid new file mode 100644 index 0000000..79b3f9e --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/editor/editor_execute.gd.uid @@ -0,0 +1 @@ +uid://vw6qifnixhin diff --git a/addons/godot_mcp_toolkit/commands/editor/editor_log_reader.gd b/addons/godot_mcp_toolkit/commands/editor/editor_log_reader.gd new file mode 100644 index 0000000..b087096 --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/editor/editor_log_reader.gd @@ -0,0 +1,385 @@ +@tool +extends RefCounted +## editor.* log/console reader: read the editor's captured output — from the +## in-memory LogBuffer (source="buffer") or a user://logs/*.log file +## (source="file") — filtered by level/text/since_id, scrubbed, and shaped into +## the entry list. Serves the console reader tool. +## +## Stateless — every handler takes (server, parameters) and returns the response +## Dictionary; the reader-spine helpers take their inputs as parameters. The log +## subsystem (LogBuffer / LogHelpers), the secret scrubber, the untrusted-content +## wrapper, and the text-filter compiler are consumed via Modules aliases. Consumed +## by editor_commands.gd via a `preload` alias. + +const Modules := preload("res://addons/godot_mcp_toolkit/core/modules.gd") +const Untrusted = Modules.Untrusted +const Scrubber = Modules.Scrubber +const Helpers = Modules.CommandHelpers +const LogHelpers = Modules.LogHelpers + + +# -- Commands ----------------------------------------------------------------- + + +static func cmd_get_console(server: Node, parameters: Dictionary) -> Dictionary: + # clear_buffer flushes stale log entries before reading. + var clear_buffer: bool = parameters.get("clear_buffer", false) == true + if clear_buffer: + Modules.LogBuffer.clear() + + var limit: int = int(parameters.get("limit", 200)) + var level_filter: Array = parameters.get("level_filter", []) + if typeof(level_filter) != TYPE_ARRAY: + level_filter = [] + var since_id: int = int(parameters.get("since_id", -1)) + var source: String = str(parameters.get("source", "buffer")) + + if limit < 1 or limit > 1000: + return MCPToolkitError.fail("INVALID_PARAMS", + "limit must be in [1, 1000] (got %d)" % limit) + if not (source in ["buffer", "file"]): + return MCPToolkitError.fail("INVALID_PARAMS", + "source must be 'buffer' or 'file' (got %s)" % source) + var valid_levels := ["info", "warning", "error"] + for level_filter_entry in level_filter: + if not str(level_filter_entry) in valid_levels: + return MCPToolkitError.fail("INVALID_PARAMS", + "level_filter entries must be one of 'info' | 'warning' | 'error' (got %s)" % str(level_filter_entry)) + + var tf := _compile_text_filter(parameters) + if tf[2] != null: + return tf[2] + var text_filter: String = tf[0] + var text_regex: RegEx = tf[1] + var regex_warning: String = tf[3] + + var result: Dictionary + if source == "file": + result = _read_console_log(server, limit, level_filter, since_id, text_filter, text_regex) + else: + result = _read_buffer_log(limit, level_filter, since_id, text_filter, text_regex) + if not regex_warning.is_empty() and result.get("success", false): + result["warning"] = regex_warning + # Headless editors don't revalidate scripts, so an error-capture request returning + # count:0 reads like "no matches" rather than "editor parse errors aren't captured + # here". Steer to script_check whenever error capture is requested (level_filter has + # "error" OR a text_filter is set) — regardless of match count. is_headless-gated, so + # the display response is byte-identical; additive hint only, capture mechanics + # untouched. + if Modules.VersionUtils.is_headless() and result.get("success", false): + var wants_error_capture := ("error" in level_filter) or (text_filter != "") + if wants_error_capture: + result["headless_hint"] = "headless editors don't revalidate scripts, so editor parse errors aren't captured here — use script_check(path) to validate a specific script's parse status." + return result + + +# -- Helpers ------------------------------------------------------------------ + + +## Compile text_filter params into [text_filter, RegEx-or-null, error-or-null, warning]. +## Delegates to Helpers.compile_text_filter for regex compilation + double-escape detection. +static func _compile_text_filter(parameters: Dictionary) -> Array: + var text_filter: String = str(parameters.get("text_filter", "")) + var is_regex: bool = bool(parameters.get("is_regex", false)) + if text_filter == "" or not is_regex: + return [text_filter, null, null, ""] + var tf := Helpers.compile_text_filter(parameters) + # tf = [RegEx-or-null, error-or-null, warning] + if tf[1] != null: + return ["", null, tf[1], ""] + return [text_filter, tf[0], null, tf[2]] + + +static func _godot_error_name(code: int) -> String: + match code: + 0: return "OK" + 7: return "ERR_FILE_NOT_FOUND" + 12: return "ERR_CANT_OPEN" + 13: return "ERR_CANT_WRITE" + 31: return "ERR_FILE_CANT_WRITE" + 32: return "ERR_FILE_CANT_READ" + _: return "Error(%d)" % code + + +# -- Buffer log reader -------------------------------------------------------- + + +static func _read_buffer_log(limit: int, level_filter: Array, since_id: int, text_filter: String = "", text_regex: RegEx = null) -> Dictionary: + var buf_result: Dictionary = Modules.LogBuffer.get_entries(limit, level_filter, since_id, text_filter, text_regex) + var entries: Array = buf_result["entries"] + for entry in entries: + var scrubbed := Scrubber.scrub(str(entry["message"]), "console") + entry["message"] = scrubbed["text"] + var has_more: bool = buf_result["has_more"] + # Capped-tail line paging: the resume cursor is next_id (fed back via since_id), + # not a next_offset, so this is cursor-less as far as the shared builder is concerned. + var next_id: int = int(buf_result["next_id"]) + var hint := "" + if has_more: + hint = "more lines remain — re-call editor.get_console with since_id = next_id (%d) until has_more is false" % next_id + var response := Modules.Pagination.build( + {"entries": Untrusted.wrap("console", "buffer", JSON.stringify(entries))}, + "lines", int(buf_result["total_lines"]), int(buf_result["returned"]), has_more, + "", 0, hint, {"next_id": next_id, "source": "buffer"}) + # On 4.2-4.4, buffer uses file tailing — warn if empty and file logging is off + # or the log file couldn't be read (locked by OS on Windows). Pre-4.5 the console + # only captures a running game's output (no editor-side capture), so an empty read + # here can mean the output simply isn't reachable — steer to the compile-status tools. + if entries.is_empty() and not Modules.LogBuffer.uses_logger_api(): + if not LogHelpers.is_file_logging_enabled(): + response["warning"] = "On Godot 4.2-4.4 the log buffer captures output by tailing the log file. Enable debug/file_logging/enable_file_logging in ProjectSettings and restart the editor for output capture to work. Editor-only output isn't captured pre-4.5 without a running game — to check compile status use script_check (one file) or lsp_project_diagnostics (whole project)." + elif Modules.LogBuffer._tail_open_failures > 0: + response["warning"] = "Log file could not be opened for reading (%d failed attempts) — the OS may be locking it. Use source=\"file\" as a fallback." % Modules.LogBuffer._tail_open_failures + # Autoload hint — scan error entries for unresolved identifiers matching autoloads. + if level_filter.is_empty() or ("error" in level_filter): + var al_hint := _scan_autoload_hints(entries) + if not al_hint.is_empty(): + response["autoload_hint"] = al_hint + return MCPToolkitSuccess.ok(response) + + +## Scan console entries for "Identifier X not declared" errors that match +## registered autoloads, returning a combined hint string (empty if none). +static func _scan_autoload_hints(entries: Array) -> String: + var re := RegEx.new() + re.compile('Identifier "(\\w+)" not declared') + var found: Array = [] + for entry in entries: + var msg: String = str(entry.get("message", "")) + var m := re.search(msg) + if m == null: + continue + var ident: String = m.get_string(1) + if ProjectSettings.has_setting("autoload/" + ident) and not (ident in found): + found.append(ident) + if found.is_empty(): + return "" + return "Some errors reference registered autoloads (%s). The editor cache may be stale — call autoload_manage to re-register them." % ", ".join(found) + + +# -- Console log reader ------------------------------------------------------- + + +static func _detect_log_level(line: String) -> String: + return LogHelpers.detect_log_level(line) + + +## Builds a source="file" LOG_UNAVAILABLE failure with the version-gated recovery hint +## (buffer-steer only on Godot 4.5+), attaching a `headless_hint` when running headless. +## A headless `--editor` never writes `user://logs/godot.log` — file logging is +## hard-disabled in editor mode on every version (the engine's `!editor` guard term, +## main.cpp) — so the missing file is expected, not a misconfiguration; steer the caller +## to source="buffer". is_headless-gated, so the display response is byte-identical. +static func _log_unavailable_for_file(message: String) -> Dictionary: + var result := MCPToolkitError.fail("LOG_UNAVAILABLE", message, + MCPToolkitError.log_unavailable_hint(Modules.LogBuffer.uses_logger_api())) + if Modules.VersionUtils.is_headless(): + result["headless_hint"] = "editor log file is unavailable — headless editors don't write one (file logging is disabled in editor mode) — use source=\"buffer\" (the default; in-memory, works headless on Godot 4.5+)." + return result + + +static func _read_console_log( + server: Node, limit: int, level_filter: Array, since_id: int, + text_filter: String = "", text_regex: RegEx = null, +) -> Dictionary: + # user://logs/ read is a narrow read-only exception to the res://-only rule. + # Path is internally constructed (not user-supplied), so no FileGuard gate. + # Honor a relocated debug/file_logging/log_path: dir-scan the configured log's + # directory for its rotation siblings, preferring the configured filename. Uses + # the RAW (un-globalized) configured path so the default stays user://logs and + # the response's log_file metadata is byte-unchanged for the common case. + var configured_log := LogHelpers.configured_log_path() + var logs_dir := configured_log.get_base_dir() + var preferred_name := configured_log.get_file() + var file_logging_enabled: bool = LogHelpers.is_file_logging_enabled() + if not DirAccess.dir_exists_absolute(logs_dir): + if not file_logging_enabled: + var _hint := "file logging is disabled — enable it in ProjectSettings → Debug → File Logging → Enable File Logging, then restart the editor" + if Modules.LogBuffer.uses_logger_api(): + _hint += "; alternatively use source=\"buffer\" (default) which captures all output in real-time" + else: + _hint += ". On Godot 4.2-4.4 source=\"buffer\" also depends on file logging, so both sources require this setting" + return _log_unavailable_for_file(_hint) + return _log_unavailable_for_file( + "no log directory at %s/ — verify file logging is enabled in ProjectSettings → Debug → File Logging → Enable File Logging" % logs_dir) + + var all_files := DirAccess.get_files_at(logs_dir) + var log_files: Array[String] = [] + for file_name in all_files: + if String(file_name).ends_with(".log"): + log_files.append(String(file_name)) + if log_files.is_empty(): + if not file_logging_enabled: + var _hint := "file logging is disabled — enable it in ProjectSettings → Debug → File Logging → Enable File Logging, then restart the editor" + if Modules.LogBuffer.uses_logger_api(): + _hint += "; alternatively use source=\"buffer\" (default) which captures all output in real-time" + else: + _hint += ". On Godot 4.2-4.4 source=\"buffer\" also depends on file logging, so both sources require this setting" + return _log_unavailable_for_file(_hint) + return _log_unavailable_for_file( + "no .log files under %s/ — verify file logging is enabled in ProjectSettings → Debug → File Logging → Enable File Logging" % logs_dir) + + var plugin_boot_time: int = server.get_plugin_boot_time() + + var chosen_file := "" + var chosen_mtime: int = 0 + var warnings: Array[String] = [] + if not file_logging_enabled: + if Modules.LogBuffer.uses_logger_api(): + warnings.append("file logging is disabled — data may be stale from a previous session; use source=\"buffer\" for real-time output") + else: + warnings.append("file logging is disabled — data may be stale from a previous session. On Godot 4.2-4.4 source=\"buffer\" also depends on file logging, so both sources may be stale") + + var preferred_log := logs_dir + "/" + preferred_name + var preferred_log_mtime: int = 0 + if FileAccess.file_exists(preferred_log): + preferred_log_mtime = FileAccess.get_modified_time(preferred_log) + if preferred_log_mtime > 0 and preferred_log_mtime >= plugin_boot_time: + chosen_file = preferred_log + chosen_mtime = preferred_log_mtime + else: + var best_file := "" + var best_mtime: int = 0 + for log_file_name in log_files: + var full_path := logs_dir + "/" + log_file_name + var mtime := FileAccess.get_modified_time(full_path) + if mtime >= plugin_boot_time and mtime > best_mtime: + best_file = full_path + best_mtime = mtime + if best_file != "": + chosen_file = best_file + chosen_mtime = best_mtime + else: + for log_file_name in log_files: + var full_path := logs_dir + "/" + log_file_name + var mtime := FileAccess.get_modified_time(full_path) + if mtime > best_mtime: + best_file = full_path + best_mtime = mtime + if best_file != "": + chosen_file = best_file + chosen_mtime = best_mtime + warnings.append("fallback to stale log — no post-boot log found") + + # Windows 4.4.0 get_modified_time self-collision. The engine's logger opens the + # live session log GENERIC_WRITE with a deny-nothing share (FileAccess WRITE never + # sets backup_save -> _SH_DENYNO on every version), so a reader's open always + # succeeds and the log is never truly locked. But on 4.4.0 get_modified_time makes + # its own CreateFileW(GENERIC_READ, FILE_SHARE_READ) probe, which denies the live + # writer -> sharing violation -> mtime reads 0. So the mtime>best_mtime loops above + # never pick the live log even though the directory enumeration (attribute-level, + # lock-immune) just listed it. Fall through with the live-log candidate (the + # configured filename when present) and let the open decide. Scoped to 4.4.0 + # (relaxed in 4.4.1; 4.5 switched to FILE_READ_ATTRIBUTES with full sharing); + # 4.2/4.3 use a handle-less _wstat that reads the live file fine, so they choose + # normally via the loops. Unreachable on POSIX (stat succeeds against the deny-nothing writer). + if chosen_file == "" and not log_files.is_empty(): + var live_name := preferred_name if log_files.has(preferred_name) else log_files[0] + chosen_file = logs_dir + "/" + live_name + + if chosen_file == "": + return _log_unavailable_for_file( + "no readable log file under %s/ — verify file logging is enabled in ProjectSettings → Debug → File Logging → Enable File Logging; playtest may have rotated the editor's log mid-session" % logs_dir) + + var file_handle := FileAccess.open(chosen_file, FileAccess.READ) + if file_handle == null: + var open_err := FileAccess.get_open_error() + # Present-but-unreadable -> LOG_BUSY; a genuinely absent file -> LOG_UNAVAILABLE. + # On Windows this happens while a game runs: the editor enables safe-save + # (FileAccess::backup_save), so our READ open requests _SH_DENYWR (deny-write), + # which the OS refuses while the play process holds the log open for writing — the + # game's own runtime reader (shared) and source=buffer are unaffected, and it clears + # when the game exits. Rarer: an external holder (AV/file-sync/backup) with no game + # running. POSIX has no mandatory share locking, so this path is unreachable there. + if FileAccess.file_exists(chosen_file) or log_files.has(chosen_file.get_file()): + return MCPToolkitError.fail("LOG_BUSY", + "log file exists but could not be read (%s)" % _godot_error_name(open_err), + MCPToolkitError.log_busy_hint(Modules.LogBuffer.uses_logger_api())) + return MCPToolkitError.fail("LOG_UNAVAILABLE", + "cannot open %s (%s)" % [chosen_file, _godot_error_name(open_err)], + MCPToolkitError.log_unavailable_hint(Modules.LogBuffer.uses_logger_api())) + var content := file_handle.get_as_text() + file_handle.close() + + var lines := content.split("\n") + var entries: Array = [] + var char_offset: int = 0 + + for line_index in range(lines.size()): + var line: String = LogHelpers.strip_ansi(lines[line_index]) + if line.strip_edges().is_empty(): + char_offset += line.length() + 1 + continue + var level := _detect_log_level(line) + if level == "info" and entries.size() > 0 and LogHelpers.is_continuation_line(line): + var previous: Dictionary = entries[-1] + if previous["level"] == "error" or previous["level"] == "warning": + previous["message"] += "\n" + line + char_offset += line.length() + 1 + continue + entries.append({ + "id": char_offset, + "level": level, + "message": line, + "timestamp_unix": null, + }) + char_offset += line.length() + 1 + + if level_filter.size() > 0: + var level_set: Array[String] = [] + for filter_entry in level_filter: + level_set.append(str(filter_entry)) + var filtered: Array = [] + for entry in entries: + if entry["level"] in level_set: + filtered.append(entry) + entries = filtered + + if since_id >= 0: + var filtered: Array = [] + for entry in entries: + if entry["id"] > since_id: + filtered.append(entry) + entries = filtered + + if text_filter != "": + var text_filtered: Array = [] + for entry in entries: + var msg: String = str(entry["message"]) + if text_regex != null: + if text_regex.search(msg): + text_filtered.append(entry) + else: + if msg.findn(text_filter) >= 0: + text_filtered.append(entry) + entries = text_filtered + + # Capture the pre-slice entry count for total_lines before the slice + # below reassigns `entries` to the capped tail. + var total_lines := entries.size() + var has_more := entries.size() > limit + if has_more: + entries = entries.slice(entries.size() - limit) + + var next_id: int = -1 + if entries.size() > 0: + next_id = entries[-1]["id"] + + for entry in entries: + var scrubbed := Scrubber.scrub(str(entry["message"]), "console") + entry["message"] = scrubbed["text"] + + # Capped-tail line paging (resume via next_id → since_id, cursor-less for the builder). + var hint := "" + if has_more: + hint = "more lines remain — re-call editor.get_console with since_id = next_id (%d) until has_more is false" % next_id + var response := Modules.Pagination.build( + {"entries": Untrusted.wrap("console", str(chosen_file), JSON.stringify(entries))}, + "lines", total_lines, entries.size(), has_more, "", 0, hint, + { + "next_id": next_id, + "log_file": chosen_file, + "log_mtime": chosen_mtime, + "warnings": warnings, + }) + return MCPToolkitSuccess.ok(response) diff --git a/addons/godot_mcp_toolkit/commands/editor/editor_log_reader.gd.uid b/addons/godot_mcp_toolkit/commands/editor/editor_log_reader.gd.uid new file mode 100644 index 0000000..c7f5145 --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/editor/editor_log_reader.gd.uid @@ -0,0 +1 @@ +uid://dejh5gkgjhldm diff --git a/addons/godot_mcp_toolkit/commands/editor/editor_rescan.gd b/addons/godot_mcp_toolkit/commands/editor/editor_rescan.gd new file mode 100644 index 0000000..c4e9947 --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/editor/editor_rescan.gd @@ -0,0 +1,116 @@ +@tool +extends RefCounted +## editor.* filesystem rescan: drive and await the editor's resource-filesystem +## rescan — a full EditorFileSystem.scan() or a targeted update_file() per path — +## then await is_scanning() via a 0.1 s poll loop. After a full scan, reload only +## the open scripts the scan actually changed and that aren't the toolkit's own +## (the reload-filter). Serves both the refresh tool and the wait_for_idle tool +## (wait_for_idle is the idle-wait alone; refresh rescans then waits then reloads). +## +## Stateless — each handler takes (parameters) and returns the response Dictionary. +## The error-buffer flush (clear_level) is reached via the Modules alias; the rescan +## itself goes straight to EditorInterface.get_resource_filesystem() / +## get_script_editor(). An extracted submodule of the editor-command group, +## reached via a `preload` alias. + +const Modules := preload("res://addons/godot_mcp_toolkit/core/modules.gd") + + +# -- Commands ----------------------------------------------------------------- + + +static func cmd_refresh(parameters: Dictionary) -> Dictionary: + # Flush stale errors before reload — fresh parse errors will be captured + # with new IDs so editor_get_console returns only current-state errors. + var errors_cleared := Modules.LogBuffer.clear_level("error") + + var file_paths_raw = parameters.get("file_paths", null) + var targeted := file_paths_raw != null and typeof(file_paths_raw) == TYPE_ARRAY \ + and (file_paths_raw as Array).size() > 0 + + var filesystem := EditorInterface.get_resource_filesystem() + var scan_waited_ms := 0 + + if targeted: + # Targeted mode: update_file() per path — O(1) per file. + var paths: Array = file_paths_raw as Array + if filesystem != null: + for path in paths: + filesystem.update_file(str(path)) + var reloaded := 0 + var script_editor := EditorInterface.get_script_editor() + if script_editor != null: + var target_set := {} + for path in paths: + target_set[str(path)] = true + for open_script in script_editor.get_open_scripts(): + if open_script is Script: + if target_set.has(open_script.resource_path): + open_script.reload(true) + reloaded += 1 + return MCPToolkitSuccess.ok({"mode": "targeted", "file_count": paths.size(), + "reloaded": reloaded, "errors_cleared": errors_cleared}) + + # Full mode: scan(), then reload ONLY the scripts the scan actually changed + # (captured via the resources_reload signal — present in all of 4.2-4.6), and + # only if they're open. NEVER reload an unchanged script: reload(true) cancels + # any suspended coroutine in it, which would break the user's @tool plugins and + # (for the toolkit's own scripts) leak the mutation dispatch lock. + # The toolkit's own scripts are skipped even when changed. + var changed := {} + var collector := func(resources: PackedStringArray) -> void: + for r in resources: + changed[str(r)] = true + if filesystem != null: + filesystem.resources_reload.connect(collector) + filesystem.scan() + var scan_start := Time.get_ticks_msec() + var scan_deadline := scan_start + 5000 + while filesystem.is_scanning() and Time.get_ticks_msec() < scan_deadline: + await Engine.get_main_loop().create_timer(0.1).timeout + scan_waited_ms = Time.get_ticks_msec() - scan_start + if filesystem.resources_reload.is_connected(collector): + filesystem.resources_reload.disconnect(collector) + var reloaded := 0 + var script_editor := EditorInterface.get_script_editor() + if script_editor != null: + for open_script in script_editor.get_open_scripts(): + if not (open_script is Script): + continue + # Reload ONLY scan-changed, non-toolkit open scripts (see + # should_reload_open_script — never cancel an unchanged/own coroutine). + if not should_reload_open_script(str(open_script.resource_path), changed): + continue + open_script.reload(true) + reloaded += 1 + return MCPToolkitSuccess.ok({"mode": "full", "reloaded": reloaded, + "scan_waited_ms": scan_waited_ms, "errors_cleared": errors_cleared}) + + +static func cmd_wait_for_idle(parameters: Dictionary) -> Dictionary: + var timeout_ms: int = int(parameters.get("timeout_ms", 10000)) + if timeout_ms < 0 or timeout_ms > 30000: + return MCPToolkitError.fail("INVALID_PARAMS", + "timeout_ms must be in [0, 30000] (got %d)" % timeout_ms) + var filesystem := EditorInterface.get_resource_filesystem() + if not filesystem.is_scanning(): + return MCPToolkitSuccess.ok({"was_scanning": false, "waited_ms": 0}) + var start := Time.get_ticks_msec() + while filesystem.is_scanning() and Time.get_ticks_msec() - start < timeout_ms: + await Engine.get_main_loop().create_timer(0.1).timeout + var elapsed := Time.get_ticks_msec() - start + if filesystem.is_scanning(): + return MCPToolkitError.fail("TIMEOUT", + "EditorFileSystem still scanning after %dms; consider increasing timeout_ms or checking editor.get_console for import errors" % elapsed) + return MCPToolkitSuccess.ok({"was_scanning": true, "waited_ms": elapsed}) + + +# -- Helpers ------------------------------------------------------------------ + + +## Whether an open script should be reloaded after a full editor.refresh scan: +## only scripts the scan actually changed, and NEVER the toolkit's own (reloading +## an unchanged or toolkit-own script would cancel a suspended coroutine — +## a wedged-dispatch-lock / editor-crash class). Pure logic so it can be unit-tested. +static func should_reload_open_script(resource_path: String, changed: Dictionary) -> bool: + return changed.has(resource_path) and not resource_path.begins_with("res://addons/godot_mcp_toolkit/") diff --git a/addons/godot_mcp_toolkit/commands/editor/editor_rescan.gd.uid b/addons/godot_mcp_toolkit/commands/editor/editor_rescan.gd.uid new file mode 100644 index 0000000..f9b9b1f --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/editor/editor_rescan.gd.uid @@ -0,0 +1 @@ +uid://8xlgkjtkr67f diff --git a/addons/godot_mcp_toolkit/commands/editor/editor_screenshot.gd b/addons/godot_mcp_toolkit/commands/editor/editor_screenshot.gd new file mode 100644 index 0000000..aa475ed --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/editor/editor_screenshot.gd @@ -0,0 +1,296 @@ +@tool +extends RefCounted +## editor.screenshot: capture the editor viewport to a PNG envelope — either the +## full main viewport, or focused on one node (select + edit + restore the prior +## selection). Caps the inline image to the requested image_detail level, handles +## headless (no viewport), and self-diagnoses a collapsed viewport: a minimized +## window returns EDITOR_VIEWPORT_UNAVAILABLE, and a wrong (non-2D/3D) main screen +## is auto-healed by switching to a 2D/3D screen and re-capturing. The +## image_response_mode / image_detail / save_path handling — inline vs disk vs both, +## the inline-resolution cap, and the res:// or user://screenshots/ save-path guard — +## is shaped by the shared ScreenshotResponse builder. +## +## Stateless — the handler takes (parameters) and returns the response Dictionary. +## Reaches the viewport / RenderingServer / EditorInterface / DisplayServer +## directly; the headless check, path normalization/edited-root lookup, and +## response shaping are reached via the Modules aliases. An extracted submodule of +## the editor-command group, reached via a `preload` alias. + +const Modules := preload("res://addons/godot_mcp_toolkit/core/modules.gd") +const Helpers = Modules.CommandHelpers + +## Below this pixel size a viewport frame is treated as collapsed rather than +## usable: Godot clamps every viewport to a hard 2x2 floor, so a zero-size +## central editor area bottoms out well under this threshold. +const MIN_USABLE_DIMENSION := 16 + +## Re-capture attempts after switching the main screen: a freshly laid-out 2D/3D +## canvas needs a compositing frame or two before its viewport reports real +## dimensions. Worst case is a few frames at the connected editor's boosted frame +## rate — cheap next to returning a blank capture. +const MAX_HEAL_FRAMES := 4 + +## Recovery hint when the editor window is minimized: no viewport is composited, +## so no frame can be captured. This is never headless — headless short-circuits +## with HEADLESS_UNSUPPORTED before any capture, so a caller must not chase that +## non-cause. +const _HINT_MINIMIZED := "The editor window is minimized, so no viewport is composited — this is not headless. Retry with force_foreground_editor:true to raise the editor automatically, or restore the window yourself; for non-visual checks use script_check." + +## Recovery hint when a main-screen switch laid out a 2D/3D canvas but it still +## composited nothing usable — an occlusion or timing edge past the bounded heal. +const _HINT_POST_HEAL := "Switched to a 2D/3D main screen but no viewport composited. Retry with force_foreground_editor:true, or use script_check for non-visual verification." + + +# -- Classification ----------------------------------------------------------- + + +## Classify a capture by its source (pre-resize) viewport dimensions. +## +## Returns [code]{"ok": true}[/code] when the frame is usable, else +## [code]{"ok": false, "reason": "no_viewport_screen", "width": w, "height": h}[/code]. +## Minimized is decided at the call site from [method DisplayServer.window_get_mode] +## (short-circuited before any capture/await), so this pure classifier only +## distinguishes a usable frame from a collapsed 2D/3D canvas. Editor-free and +## deterministic — the reason the capture decision lives here rather than inline. +## [param source_width] and [param source_height] are the viewport's pre-resize +## pixel dimensions. +static func classify_capture(source_width: int, source_height: int) -> Dictionary: + if source_width < MIN_USABLE_DIMENSION or source_height < MIN_USABLE_DIMENSION: + return { + "ok": false, + "reason": "no_viewport_screen", + "width": source_width, + "height": source_height, + } + return {"ok": true} + + +# -- Commands ----------------------------------------------------------------- + + +static func cmd_screenshot(parameters: Dictionary) -> Dictionary: + if Modules.VersionUtils.is_headless(): + # Redirect must be headless-accurate: script_check is the reliable alternative; + # editor_get_console gives RUNTIME output only (its editor parse-error capture is + # itself headless-degraded), so it is not a substitute for visual verification. + return MCPToolkitError.fail("HEADLESS_UNSUPPORTED", + "editor.screenshot requires a display server (no viewport in headless mode)", + "Use script_check to verify a script's parse status; editor_get_console captures runtime output only (headless editors don't revalidate scripts, so editor parse errors aren't captured there).") + + # Reject a bad image_detail before any capture so a typo can't slip through as + # full-res (the pure dims calculator treats an unknown value as native — the guard + # lives here, once, so both capture paths reject uniformly). + if Modules.ScreenshotResponse.detail_of(parameters).is_empty(): + return MCPToolkitError.fail("INVALID_PARAMS", + "image_detail must be one of [full, mid, low] (got %s)" + % str(parameters.get("image_detail", ""))) + + var force_foreground := bool(parameters.get("force_foreground_editor", false)) + var remediation: Array[String] = [] + + # Minimized suspends compositing, so no frame can be captured. Detect it up + # front — before any await frame_post_draw, which would never fire on a + # suspended-render editor and hang the handler until the server times out. + if _window_is_minimized(): + if not force_foreground: + return MCPToolkitError.fail("EDITOR_VIEWPORT_UNAVAILABLE", + "editor viewport unavailable: the editor window is minimized", _HINT_MINIMIZED) + await _foreground_editor() + remediation.append("foregrounded_editor") + + var node_path := str(parameters.get("node_path", "")) + node_path = Helpers.normalize_editor_path(node_path) + + if not node_path.is_empty(): + return await _capture_node(parameters, node_path, remediation) + return await _capture_standard(parameters, remediation) + + +# -- Capture paths ------------------------------------------------------------ + + +# Node-focused screenshot: select + edit + capture a specific node, then restore +# the prior selection. A wrong main screen is healed against the node's own +# viewport kind (3D for a Node3D, else 2D). +static func _capture_node(parameters: Dictionary, node_path: String, remediation: Array[String]) -> Dictionary: + var root := Helpers.get_edited_root() + if root == null: + return MCPToolkitError.fail("NO_SCENE", "no edited scene") + var node: Variant = null + if node_path == ".": + node = root + else: + node = root.get_node_or_null(node_path) + if node == null: + return MCPToolkitError.fail("NOT_FOUND", "no node at %s" % node_path, MCPToolkitError.HINT_NODE_PATH) + + var wants_3d := node is Node3D + + var selection := EditorInterface.get_selection() + var prior_selection: Array = [] + if selection != null: + for selected_node in selection.get_selected_nodes(): + prior_selection.append(selected_node) + selection.clear() + selection.add_node(node) + EditorInterface.edit_node(node) + await RenderingServer.frame_post_draw + + var image := await _grab_usable_image(wants_3d, remediation) + if image == null: + _restore_selection(selection, prior_selection) + return MCPToolkitError.fail("EDITOR_VIEWPORT_UNAVAILABLE", + "editor viewport unavailable: no 2D/3D viewport composited a usable frame", _HINT_POST_HEAL) + + _restore_selection(selection, prior_selection) + + var png_bytes := image.save_png_to_buffer() + if png_bytes.is_empty(): + return MCPToolkitError.fail("EMPTY_CONTENT", + "node '%s' produced no visible image. Node may lack visual content (no texture, no mesh). Use editor_screenshot without node_path for a full viewport capture instead." % node_path) + var inline := _downscale_inline_png(image, parameters) + var response := Modules.ScreenshotResponse.build( + parameters, png_bytes, image.get_width(), image.get_height(), + ["res://", "user://screenshots/"], + inline["png_bytes"], int(inline["width"]), int(inline["height"])) + if response.get("success") == false: + return response + # Inline mode echoes the requested node path; a disk/both response already carries + # the FILE path in "path" (which supersedes the echo — the caller knows its input). + if not response.has("path"): + response["path"] = node_path + if not remediation.is_empty(): + response["remediation"] = remediation + return MCPToolkitSuccess.ok(response) + + +# Standard viewport screenshot. Defaults the heal target to the 2D viewport. +static func _capture_standard(parameters: Dictionary, remediation: Array[String]) -> Dictionary: + # Freshness await before the first read: a throttled unfocused editor can + # otherwise hand back a slightly stale frame. Safe now that minimized is + # short-circuited up front — this only runs while the editor is compositing. + await RenderingServer.frame_post_draw + + var image := await _grab_usable_image(false, remediation) + if image == null: + return MCPToolkitError.fail("EDITOR_VIEWPORT_UNAVAILABLE", + "editor viewport unavailable: no 2D/3D viewport composited a usable frame", _HINT_POST_HEAL) + + var png_bytes := image.save_png_to_buffer() + if png_bytes.is_empty(): + return MCPToolkitError.fail("INTERNAL", "save_png_to_buffer returned empty") + + var inline := _downscale_inline_png(image, parameters) + var response := Modules.ScreenshotResponse.build( + parameters, png_bytes, image.get_width(), image.get_height(), + ["res://", "user://screenshots/"], + inline["png_bytes"], int(inline["width"]), int(inline["height"])) + if response.get("success") == false: + return response + if not remediation.is_empty(): + response["remediation"] = remediation + return MCPToolkitSuccess.ok(response) + + +# Encode the downscaled inline PNG for the requested image_detail cap, but only when +# an inline image is actually returned (inline/both mode) and the level shrinks the +# frame — disk-only returns the full-res file, so it needs no inline buffer. Returns +# {png_bytes, width, height}; png_bytes is empty (and dims -1) when no downscale +# applies, which ScreenshotResponse.build reads as "use the full-res buffer inline". +static func _downscale_inline_png(image: Image, parameters: Dictionary) -> Dictionary: + var empty := {"png_bytes": PackedByteArray(), "width": -1, "height": -1} + if Modules.ScreenshotResponse.mode_of(parameters) == "disk": + return empty + var detail := Modules.ScreenshotResponse.detail_of(parameters) + var target := Modules.ScreenshotResponse.image_detail_dims( + image.get_width(), image.get_height(), detail) + if target.x == image.get_width() and target.y == image.get_height(): + return empty + # duplicate() is typed Ref; the explicit Image local coerces (loud on a + # mismatch, unlike `as`). Resize the copy so the full-res buffer stays intact for disk. + var inline_image: Image = image.duplicate() + inline_image.resize(target.x, target.y, Image.INTERPOLATE_LANCZOS) + return { + "png_bytes": inline_image.save_png_to_buffer(), + "width": inline_image.get_width(), + "height": inline_image.get_height(), + } + + +# -- Viewport heal + foreground ---------------------------------------------- + + +# Fetch a usable viewport image, auto-healing a wrong main screen. Reads the +# requested viewport, and if it is collapsed (2x2 floor, not minimized — that is +# handled up front) switches to the 2D/3D main screen and re-checks across a +# bounded frame loop, breaking the instant a usable frame arrives. Appends +# "switched_main_screen" to [param remediation] when a switch was performed. +# Returns the usable Image, or null when nothing composited within the cap. +static func _grab_usable_image(wants_3d: bool, remediation: Array[String]) -> Image: + var image := _read_viewport_image(wants_3d) + if image != null and classify_capture(image.get_width(), image.get_height())["ok"]: + return image + + # Collapsed and not minimized ⇒ the 2D/3D canvas is not the active main screen. + # Lay it out and re-capture; there is no getter for the prior screen, so the + # switch is one-way and disclosed via remediation rather than restored. + EditorInterface.set_main_screen_editor("3D" if wants_3d else "2D") + var switched := false + for _attempt in range(MAX_HEAL_FRAMES): + await RenderingServer.frame_post_draw + image = _read_viewport_image(wants_3d) + if image != null and classify_capture(image.get_width(), image.get_height())["ok"]: + switched = true + break + + if switched: + remediation.append("switched_main_screen") + return image + return null + + +# Read the current texture of the requested editor viewport (3D falls back to 2D +# when no 3D viewport exists). Returns null when no viewport or texture is +# available. +static func _read_viewport_image(wants_3d: bool) -> Image: + var viewport: SubViewport = null + if wants_3d: + viewport = EditorInterface.get_editor_viewport_3d(0) + if viewport == null: + viewport = EditorInterface.get_editor_viewport_2d() + if viewport == null: + return null + return viewport.get_texture().get_image() + + +# Un-minimize + raise + focus the editor window, then await a composite frame so +# the subsequent capture sees a laid-out viewport. window_set_mode(WINDOWED) is +# applied only when minimized so a maximized editor is not un-maximized; the +# 4.6+-deprecated Window.move_to_foreground() is deliberately avoided in favour of +# DisplayServer.window_move_to_foreground() + Window.grab_focus(). +static func _foreground_editor() -> void: + if _window_is_minimized(): + DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_WINDOWED, 0) + DisplayServer.window_move_to_foreground(0) + var base := EditorInterface.get_base_control() + if base != null: + var win := base.get_window() + if win != null: + win.grab_focus() + await RenderingServer.frame_post_draw + + +# -- Helpers ------------------------------------------------------------------ + + +static func _window_is_minimized() -> bool: + return DisplayServer.window_get_mode(0) == DisplayServer.WINDOW_MODE_MINIMIZED + + +static func _restore_selection(selection: EditorSelection, prior_selection: Array) -> void: + if selection == null: + return + selection.clear() + for selected_node in prior_selection: + if is_instance_valid(selected_node): + selection.add_node(selected_node) diff --git a/addons/godot_mcp_toolkit/commands/editor/editor_screenshot.gd.uid b/addons/godot_mcp_toolkit/commands/editor/editor_screenshot.gd.uid new file mode 100644 index 0000000..b2914c9 --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/editor/editor_screenshot.gd.uid @@ -0,0 +1 @@ +uid://bfqn5wl6d3b81 diff --git a/addons/godot_mcp_toolkit/commands/editor_helpers.gd b/addons/godot_mcp_toolkit/commands/editor_helpers.gd new file mode 100644 index 0000000..2713a12 --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/editor_helpers.gd @@ -0,0 +1,884 @@ +@tool +extends RefCounted +## Shared helpers used across multiple command handlers. +## Eliminates duplication of scene-node resolution, class hierarchy +## checks, file deletion, directory creation, and log-level detection. + +## NOTE: This file is preloaded by core/modules.gd, so it CANNOT import core/modules.gd +## (circular dependency). Use direct preloads for dependencies instead. +const Coerce := preload("res://addons/godot_mcp_toolkit/contract/coerce.gd") +const FileGuard := preload("res://addons/godot_mcp_toolkit/security/file_guard.gd") +const VersionUtils := preload("res://addons/godot_mcp_toolkit/versioning/mcp_version_utils.gd") +const PropertySetCheck := preload("res://addons/godot_mcp_toolkit/contract/property_set_check.gd") + + +# -- Property coercion --------------------------------------------------------- + + +## Validate and coerce a raw JSON value for setting on a node property. +## Returns {"ok": true, "value": } on success. +## Returns {"ok": false, "code": String, "error": String} on failure. +## Auto-coerces String → NodePath when the existing property value is NodePath. +## Rejects unknown property names (not in the instance's property list). +static func coerce_for_property( + node: Object, property_name: String, raw_value: Variant, +) -> Dictionary: + if not _has_property(node, property_name): + return {"ok": false, "code": "PROPERTY_NOT_FOUND", + "error": "property '%s' not found on %s" % [property_name, node.get_class()]} + + var missing := Coerce.check_resource_paths(raw_value) + if missing != "": + return {"ok": false, "code": "LOAD_FAILED", + "error": "resource not found: %s" % missing} + + var coerced = Coerce.coerce_value(raw_value) + + if typeof(coerced) == TYPE_DICTIONARY \ + and (coerced as Dictionary).has("_coerce_error"): + return {"ok": false, "code": "INVALID_VALUE", + "error": str(coerced["_coerce_error"])} + + var old_value = node.get(property_name) + if typeof(old_value) == TYPE_NODE_PATH and typeof(coerced) == TYPE_STRING: + coerced = NodePath(str(coerced)) + + return {"ok": true, "value": coerced} + + +## Compile a regex text_filter. Returns [RegEx-or-null, error-or-null, warning]. +## Handles double-escaped metacharacters from MCP transport. +static func compile_text_filter(parameters: Dictionary) -> Array: + var text_filter: String = str(parameters.get("text_filter", "")) + var is_regex: bool = bool(parameters.get("is_regex", false)) + if text_filter == "" or not is_regex: + return [null, null, ""] + var regex := RegEx.new() + if regex.compile("(?i)" + text_filter) != OK: + var err := MCPToolkitError.fail("INVALID_PARAMS", + "text_filter is not a valid regex (is_regex=true). " + + "To search for literal text, omit is_regex or set it to false. " + + "For regex, check for unbalanced groups () [] or unescaped metacharacters.") + return [null, err, ""] + var warning := _detect_double_escaped_regex(text_filter) + return [regex, null, warning] + + +## Detect likely double-escaped regex metacharacters. +## Checks both single (\\d) and double (\\\\d) levels of over-escaping. +static func _detect_double_escaped_regex(pattern: String) -> String: + for letter in ["d", "D", "w", "W", "s", "S", "b", "B"]: + if pattern.find("\\\\\\\\" + letter) >= 0: + return ( + "Pattern contains '\\\\\\\\%s' (multiple layers of backslash escaping). " + + "The regex metacharacter \\%s is over-escaped — use a POSIX " + + "character class instead (e.g. [0-9] for \\d, [a-zA-Z0-9_] for \\w)." + ) % [letter, letter, letter] + if pattern.find("\\\\" + letter) >= 0: + return ( + "Pattern contains '\\\\%s' (literal backslash + '%s'). " + + "If you meant the regex metacharacter \\%s, your backslash " + + "is likely double-escaped. Use a POSIX character class instead " + + "(e.g. [0-9] for \\d, [a-zA-Z0-9_] for \\w)." + ) % [letter, letter, letter] + return "" + + +## Coerce and set a property on a node, handling compound paths (: and /) +## and dedicated setter APIs (shader_parameter/ on ShaderMaterial). +## Returns {"ok": true} on success, {"ok": false, "code": ..., "error": ...} +## on failure. Success results include an "_undo" key with info for the caller +## to register UndoRedo (type, effective path, old value, old resource refs). +## +## When make_unique is true and the target sub-resource is external (.tres), +## it is automatically duplicated as an inline copy before setting. +## This replicates the Inspector's "Make Unique" behavior. +## +## Path types: +## No colon — set directly on node (slash paths handled by Godot's _set). +## Single : — convert to / for node-level override (persists in .tscn). +## Multi : — manual sub-resource navigation; inline OK, external rejected +## unless make_unique is set. +static func set_property_compound( + node: Object, property_name: String, raw_value: Variant, + make_unique: bool = false, +) -> Dictionary: + # --- Coerce (shared by all paths) --- + var missing := Coerce.check_resource_paths(raw_value) + if missing != "": + return {"ok": false, "code": "LOAD_FAILED", + "error": "resource not found: %s" % missing} + var coerced = Coerce.coerce_value(raw_value) + if typeof(coerced) == TYPE_DICTIONARY \ + and (coerced as Dictionary).has("_coerce_error"): + return {"ok": false, "code": "INVALID_VALUE", + "error": str(coerced["_coerce_error"])} + + # --- No colon: set directly on node --- + if ":" not in property_name: + var _undo_old = node.get(property_name) + node.set(property_name, coerced) + var result := _check_set_readback(node, property_name, _undo_old, coerced, property_name) + if result.get("ok", false): + result["_undo"] = {"type": "property", "path": property_name, "old": _undo_old} + else: + # Dropped: a bound setter (position/modulate) may have zeroed the value; + # restore the prior value so the failure is non-destructive. + node.set(property_name, _undo_old) + return result + + var parts := property_name.split(":") + + # --- Navigate sub-resource chain (all colon paths) --- + # For both single-colon and multi-colon, navigate to the target + # sub-resource. When make_unique is set, duplicate EACH external + # resource in the chain from the node down — this prevents + # accidentally modifying shared .tres resources at any level. + # Capture the original resource BEFORE make_unique for undo. + var _undo_old_resource: Variant = null + if make_unique and parts.size() >= 2: + _undo_old_resource = node.get(parts[0]) + + var target: Object = node + var made_unique: Array = [] # Track which resources were duplicated. + for i in range(parts.size() - 1): + var sub = target.get(parts[i]) + if sub == null or not (sub is Object): + return {"ok": false, "code": "NOT_FOUND", + "error": "sub-resource '%s' is null on %s" % [parts[i], node.get_class()]} + if make_unique and sub is Resource \ + and _is_external_resource(sub as Resource): + var old_path: String = (sub as Resource).resource_path + sub = (sub as Resource).duplicate() + target.set(parts[i], sub) + made_unique.append({ + "property": parts[i], + "was": old_path, + "now": "inline", + }) + target = sub + + var final_prop := parts[-1] + + # --- Single colon: try slash-path override first --- + if parts.size() == 2: + var slash_path := parts[0] + "/" + parts[1] + var _undo_old_slash = node.get(slash_path) + + # Try 1: node-level override via slash path. + # Some node types (MeshInstance3D) handle slash-path _set() and + # persist the value as a node property in .tscn. + node.set(slash_path, coerced) + var readback = node.get(slash_path) + if readback != null: + var result := _check_set_readback_value(_undo_old_slash, readback, coerced, property_name) + if not made_unique.is_empty(): + result["made_unique"] = made_unique + if result.get("ok", false): + var undo := {"type": "property", "path": slash_path, "old": _undo_old_slash} + if _undo_old_resource != null and not made_unique.is_empty(): + undo["old_resource_prop"] = parts[0] + undo["old_resource"] = _undo_old_resource + result["_undo"] = undo + return result + + # --- Direct sub-resource mutation --- + # For single-colon when slash-path didn't work, and all multi-colon. + # Persists only for inline sub-resources (.tscn [sub_resource] section). + # External resources: in-memory only → warn. + var _undo_old_sub = _read_sub_property(target, final_prop) + _write_sub_property(target, final_prop, coerced) + var readback = _read_sub_property(target, final_prop) + var result := _check_set_readback_value(_undo_old_sub, readback, coerced, property_name) + if not made_unique.is_empty(): + result["made_unique"] = made_unique + if result.get("ok", false): + # Sub-resource direct mutation — undo navigates the chain via helper. + var undo := {"type": "sub_resource", "path": property_name, + "old": _undo_old_sub, "new": coerced} + if _undo_old_resource != null and not made_unique.is_empty(): + undo["old_resource_prop"] = parts[0] + undo["old_resource"] = _undo_old_resource + result["_undo"] = undo + if result.get("ok", false) \ + and target is Resource \ + and _is_external_resource(target as Resource): + result["warning"] = ( + "Value was set on a shared external sub-resource in memory. " + + "This change may not persist after save/reload. " + + "Retry with make_unique: true to auto-duplicate all external " + + "resources in the chain as inline copies.") + return result + + +## Read a compound-path property from a node, handling colon-chain paths. +## Returns {"ok": true, "value": } on success, +## {"ok": false, "code": ..., "error": ...} on failure. +## +## For single-colon paths, tries node-level override first (slash conversion), +## falls back to sub-resource read (returns resource defaults when no override). +## This matches Inspector behavior: show the effective value. +static func get_property_compound(node: Object, property_name: String) -> Dictionary: + # No colon — read directly from node (handles slash-only compound paths). + if ":" not in property_name: + return {"ok": true, "value": Coerce.serialize_value(node.get(property_name))} + + var parts := property_name.split(":") + + # Single colon: try node-level override first, then sub-resource default. + if parts.size() == 2: + var slash_path := parts[0] + "/" + parts[1] + var value = node.get(slash_path) + if value != null: + return {"ok": true, "value": Coerce.serialize_value(value)} + # Null fallback: navigate to sub-resource and read from it. + var sub = node.get(parts[0]) + if sub == null or not (sub is Object): + return {"ok": false, "code": "NOT_FOUND", + "error": "sub-resource '%s' is null on %s" % [parts[0], node.get_class()]} + var fallback_value = _read_sub_property(sub, parts[1]) + return {"ok": true, "value": Coerce.serialize_value(fallback_value)} + + # Multi colon: manual sub-resource navigation. + var target: Object = node + var final_prop := parts[-1] + for i in range(parts.size() - 1): + var sub = target.get(parts[i]) + if sub == null or not (sub is Object): + return {"ok": false, "code": "NOT_FOUND", + "error": "sub-resource '%s' is null on %s" % [parts[i], node.get_class()]} + target = sub + var value = _read_sub_property(target, final_prop) + return {"ok": true, "value": Coerce.serialize_value(value)} + + +# -- Compound path helpers (private) ------------------------------------------ + + +## Read a property from a sub-resource, using dedicated getters where needed. +static func _read_sub_property(target: Object, prop: String) -> Variant: + if prop.begins_with("shader_parameter/") and target is ShaderMaterial: + return (target as ShaderMaterial).get_shader_parameter( + prop.trim_prefix("shader_parameter/")) + return target.get(prop) + + +## Write a property on a sub-resource, using dedicated setters where needed. +static func _write_sub_property(target: Object, prop: String, value: Variant) -> void: + if prop.begins_with("shader_parameter/") and target is ShaderMaterial: + (target as ShaderMaterial).set_shader_parameter( + prop.trim_prefix("shader_parameter/"), value) + else: + target.set(prop, value) + + +## Check whether a Resource is stored externally (standalone .tres/.res file) +## vs inline (built-in sub-resource in a scene or parent resource). +static func _is_external_resource(res: Resource) -> bool: + var path := res.resource_path + if path.is_empty(): + return false + # Inline sub-resources have paths like "res://scene.tscn::unique_id". + if "::" in path: + return false + return true + + +## Verify a SET succeeded by reading back from the target and comparing. +## target + readback_prop define WHERE to read the post-set value; before is the +## pre-set value (the property's type oracle); original_path is for errors. +static func _check_set_readback( + target: Object, readback_prop: String, before: Variant, + coerced: Variant, original_path: String, +) -> Dictionary: + var after = _read_sub_property(target, readback_prop) + return _check_set_readback_value(before, after, coerced, original_path) + + +## Map the [method describe_set_drop] tri-state onto the {ok:…} result shape the +## compound-path callers expect. A clean write keeps the historical +## {"ok": true, "value": coerced}; an ADJUSTED write adds a "warning" (the compound +## callers already propagate result["warning"]); a DROPPED write is SET_FAILED. +static func _check_set_readback_value( + before: Variant, after: Variant, coerced: Variant, original_path: String, +) -> Dictionary: + var outcome := describe_set_drop(before, after, coerced, original_path) + match str(outcome.get("status", "")): + "dropped": + return {"ok": false, "code": "SET_FAILED", "error": str(outcome.get("error", ""))} + "adjusted": + return {"ok": true, "value": coerced, "warning": str(outcome.get("warning", ""))} + _: + return {"ok": true, "value": coerced} + + +## Property-set-outcome classifier — delegates to the runtime-safe pure leaf +## [code]contract/property_set_check.gd[/code] (the SSOT shared with the runtime +## autoload's [code]runtime.set_property[/code]). Kept here as +## [code]Helpers.describe_set_drop[/code] so the editor node/scene property-set +## paths reach it through their existing helper facade with no call-site churn. +## Returns the tri-state {"status": "ok" | "adjusted" (+warning) | "dropped" (+error)}. +static func describe_set_drop( + before: Variant, after: Variant, coerced: Variant, path: String, +) -> Dictionary: + return PropertySetCheck.describe_set_drop(before, after, coerced, path) + + +## Check whether a property name exists on an object instance. +## Uses get_property_list() which covers built-in, @export, and metadata. +static func _has_property(obj: Object, property_name: String) -> bool: + for p in obj.get_property_list(): + if p["name"] == property_name: + return true + return false + + +# -- Scene node resolution ----------------------------------------------------- + + +static func get_edited_root() -> Node: + return EditorInterface.get_edited_scene_root() + + +static func resolve_scene_node(node_path: String) -> Variant: + var root := get_edited_root() + if root == null: + return null + if node_path.is_empty() or node_path == ".": + return root + return root.get_node_or_null(node_path) + + +## Translate runtime-style /root/ paths to editor-relative paths. +## Agents often pass "/root/Main/Player" when they mean "./Player". +## Editor commands operate on the edited scene tree where the root +## is always "." — there is no /root node. The first segment after +## /root/ is the runtime scene root name and is stripped. +## When the path has children ("/root/X/Child"), the scene-name segment +## is stripped unconditionally (casing may differ between runtime and +## editor). When the path is "/root/X" alone (no children), we validate +## X against the current edited scene root name (case-insensitive) so +## that clearly non-existent paths like "/root/NoSuch" propagate as-is +## and produce NOT_FOUND from the caller's get_node_or_null. +static func normalize_editor_path(raw_path: String) -> String: + if not raw_path.begins_with("/root/") and raw_path != "/root": + return raw_path + + # "/root" alone → "." + if raw_path == "/root": + return "." + + # Strip "/root/" prefix — remainder is "SceneName" or "SceneName/Child/..." + var after_root := raw_path.substr(6) # len("/root/") == 6 + + var slash_idx := after_root.find("/") + if slash_idx < 0: + # "/root/SceneName" (no further children) — validate against the + # current edited scene root. If the name doesn't match, the path + # refers to a non-existent node; return raw_path so the caller's + # get_node_or_null produces NOT_FOUND. + var edited_root := EditorInterface.get_edited_scene_root() + if edited_root != null and after_root.to_lower() != edited_root.name.to_lower(): + return raw_path + return "." + + # "/root/SceneName/Child/..." → "./Child/..." + return "." + after_root.substr(slash_idx) + + +# -- Class hierarchy checks ---------------------------------------------------- + + +static func class_descends_from(type_name: String, base: String) -> bool: + if ClassDB.class_exists(type_name): + return ClassDB.is_parent_class(type_name, base) + for entry in ProjectSettings.get_global_class_list(): + if str(entry.get("class", "")) == type_name: + return class_descends_from(str(entry.get("base", "")), base) + return false + + +static func class_base_chain(type_name: String) -> String: + var chain := PackedStringArray() + var current := type_name + var depth := 0 + while not current.is_empty() and depth < 16: + chain.append(current) + if ClassDB.class_exists(current): + var parent := ClassDB.get_parent_class(current) + if parent.is_empty(): + break + current = parent + else: + var found := false + for entry in ProjectSettings.get_global_class_list(): + if str(entry.get("class", "")) == current: + current = str(entry.get("base", "")) + found = true + break + if not found: + break + depth += 1 + return " -> ".join(chain) + + +## Resolve a class name to its kind. Returns {"kind": "native"|"global"|"", +## "entry": Dictionary} -- "entry" is the global-class-list row for a global +## class, {} otherwise. Caller owns the unknown-class error + the descends-from check. +static func resolve_class_kind(type_name: String) -> Dictionary: + if ClassDB.class_exists(type_name): + return {"kind": "native", "entry": {}} + for entry in ProjectSettings.get_global_class_list(): + if str(entry.get("class", "")) == type_name: + return {"kind": "global", "entry": entry} + return {"kind": "", "entry": {}} + + +# -- Scene tab management ------------------------------------------------------ + + +## Open a scene in the editor using call_deferred to avoid deferred-queue +## collisions (godotengine/godot#75669). Yields one frame so the editor +## fully settles before the next MCP command executes. +static func open_scene_deferred(file_path: String) -> bool: + # Never open into an active EditorFileSystem scan (the load reads + # inconsistent state and can crash). Returns false on scan-idle timeout so + # callers can abort rather than collide with the scan. + if not await MCPToolkitSafeSceneOps.wait_for_scan_idle(): + return false + EditorInterface.open_scene_from_path.call_deferred(file_path) + await (Engine.get_main_loop() as SceneTree).process_frame + return true + + +## Attempt to close the editor tab for `file_path`. +## Returns {closed: true, switched: bool} or {closed: false, reason: String}. +## +## On success, [code]unsaved_changes_discarded: bool[/code] is included only on +## Godot 4.7+ (where a dirty query is bound); it reports whether the closed tab +## had unsaved edits that closing threw away. The key is absent on 4.5/4.6 — +## absence means "not determinable", distinct from a detected-clean [code]false[/code]. +## +## Reason codes: +## "not_open" — file has no open editor tab +## "no_api" — Godot 4.2–4.4 (no close_scene method) +## +## On 4.5+ the engine auto-creates an empty scene if the last tab is closed, +## so there is no "last_tab" guard — the caller never needs to worry about it. +## +## SAFETY: performs at most ONE open_scene_from_path + close_scene cycle. +## Never loops. Uses call_deferred for both open and close to avoid +## deferred-queue collisions (godotengine/godot#75669). Yields a frame +## after each deferred call so the editor fully settles before the +## next MCP command can execute. +## +## NOTE: does NOT restore the previously-active tab. After closing a +## non-active tab, Godot auto-switches to an adjacent tab. Restoring +## via a third open_scene_from_path triggers a benign but noisy +## _set_main_scene_state deferred-queue error in the engine — not +## worth the console spam. +static func close_scene_tab_safe(file_path: String) -> Dictionary: + var open_scenes := EditorInterface.get_open_scenes() + if not open_scenes.has(file_path): + return {"closed": false, "reason": "not_open"} + + # Version gate: close_scene() requires 4.5+. + if not EditorInterface.has_method("close_scene"): + return {"closed": false, "reason": "no_api"} + + var tree := Engine.get_main_loop() as SceneTree + + # Best-effort dirty disclosure: capture whether the tab has unsaved edits + # BEFORE the switch/close mutate editor state, so the caller can warn that + # closing discards them. get_unsaved_scenes() is only bound in Godot 4.7; + # older editors expose no dirty query, so this stays absent below 4.7 and + # the field is simply omitted rather than reported as a false "clean". + var disclose_unsaved := EditorInterface.has_method("get_unsaved_scenes") + var unsaved_discarded := false + if disclose_unsaved: + # Call via has_method + call: the method is unbound below 4.7, so a direct + # static reference would parse-fail on older editors. + var unsaved: PackedStringArray = EditorInterface.call("get_unsaved_scenes") + unsaved_discarded = file_path in unsaved + + # If the target is not the active tab, activate it first. + # Use call_deferred to avoid deferred-queue collisions + # (godotengine/godot#75669 — direct calls from plugin code can crash). + var current_root := get_edited_root() + var current_path := current_root.scene_file_path if current_root else "" + var switched := (current_path != file_path) + if switched: + if not await open_scene_deferred(file_path): + return {"closed": false, "reason": "scan_timeout"} + + # Close the now-active tab via call_deferred for the same reason. + EditorInterface.call_deferred("close_scene") + await tree.process_frame + + var result := {"closed": true, "switched": switched} + # Only surface the field where it is actually detectable (4.7+); absence + # signals "not determinable here", distinct from a detected clean tab. + if disclose_unsaved: + result["unsaved_changes_discarded"] = unsaved_discarded + return result + + +# -- File operations ----------------------------------------------------------- + + +## Delete a res:// file and its companion files (.uid, .import). +## Clears the in-memory ResourceUID cache to prevent stale-UID errors. +## Returns {success: true, path: String} or an MCPToolkitError dict. +static func delete_res_file(file_path: String, companions: Array = [".uid"]) -> Dictionary: + # Capture the UID before deleting so we can evict it from the cache. + var uid: int = ResourceLoader.get_resource_uid(file_path) + + var directory := DirAccess.open("res://") + if directory == null: + return MCPToolkitError.fail("INTERNAL", "DirAccess.open(res://) returned null") + var relative_path := file_path.substr("res://".length()) + var remove_error := directory.remove(relative_path) + if remove_error != OK: + return MCPToolkitError.fail("DELETE_FAILED", + "DirAccess.remove returned %d (path=%s)" % [remove_error, file_path]) + for suffix in companions: + var companion_relative: String = relative_path + str(suffix) + if directory.file_exists(companion_relative): + directory.remove(companion_relative) + + # Evict the UID from the in-memory singleton so the engine doesn't + # reference a now-deleted path. On clean shutdown the cache file + # (uid_cache.bin) is rewritten without the removed entry. + if uid != -1 and ResourceUID.has_id(uid): + ResourceUID.remove_id(uid) + + return {"success": true, "path": file_path} + + +## Ensure parent directory exists, auto-creating if needed. +## Returns {ok: true, dirs_created: bool} or an MCPToolkitError dict on failure. +static func ensure_parent_dir(file_path: String, context: String = "") -> Dictionary: + var parent_dir := file_path.get_base_dir() + if DirAccess.dir_exists_absolute(parent_dir): + return {"ok": true, "dirs_created": false} + var mkdir_err := DirAccess.make_dir_recursive_absolute(parent_dir) + if mkdir_err != OK: + return MCPToolkitError.fail("PARENT_NOT_FOUND", + "parent directory %s does not exist and auto-create failed (err %d); call folder.create manually" % [parent_dir, mkdir_err]) + if not context.is_empty(): + push_warning("[MCPTools] auto-created directory %s for %s" % [parent_dir, context]) + return {"ok": true, "dirs_created": true} + + +# -- EditorFileSystem targeted updates ---------------------------------------- + + +## Targeted index: call update_file() and poll until indexed or timeout. +## Falls back to scan() if update_file() alone does not index the file +## (e.g. the parent directory is new and not yet in EditorFileSystem). +## Returns {indexed: bool, file_class: String, elapsed_ms: int}. +static func ensure_file_indexed(file_path: String, timeout_ms: int = 3000) -> Dictionary: + var filesystem := EditorInterface.get_resource_filesystem() + if filesystem == null: + return {"indexed": false, "file_class": "", "elapsed_ms": 0} + filesystem.update_file(file_path) + var start := Time.get_ticks_msec() + while filesystem.get_file_type(file_path) == "" and Time.get_ticks_msec() - start < timeout_ms: + await Engine.get_main_loop().create_timer(0.1).timeout + if filesystem.get_file_type(file_path) != "": + var file_class := filesystem.get_file_type(file_path) + return {"indexed": true, "file_class": file_class, "elapsed_ms": Time.get_ticks_msec() - start} + # Fallback: update_file() could not index — parent dir may be unknown. Full scan. + filesystem.scan() + while filesystem.is_scanning() and Time.get_ticks_msec() - start < timeout_ms: + await Engine.get_main_loop().create_timer(0.1).timeout + var elapsed := Time.get_ticks_msec() - start + var file_class := filesystem.get_file_type(file_path) + var result := {"indexed": file_class != "", "file_class": file_class, "elapsed_ms": elapsed} + if not result["indexed"]: + result["hint"] = "indexed is advisory — script_check, resource_load, and scene_open work regardless. Call editor_refresh only if asset_list visibility is needed." + return result + + +## Targeted deindex: call update_file() on a deleted path and poll until +## removed from the index. Falls back to scan() if update_file() alone +## does not clear the entry (directory-level or engine quirk). +## Returns {removed: bool, elapsed_ms: int}. +static func ensure_file_removed(file_path: String, timeout_ms: int = 3000) -> Dictionary: + var filesystem := EditorInterface.get_resource_filesystem() + if filesystem == null: + return {"removed": false, "elapsed_ms": 0} + filesystem.update_file(file_path) + var start := Time.get_ticks_msec() + while filesystem.get_file_type(file_path) != "" and Time.get_ticks_msec() - start < timeout_ms: + await Engine.get_main_loop().create_timer(0.1).timeout + if filesystem.get_file_type(file_path) == "": + return {"removed": true, "elapsed_ms": Time.get_ticks_msec() - start} + # Fallback: update_file() did not remove the entry — full scan. + filesystem.scan() + while filesystem.is_scanning() and Time.get_ticks_msec() - start < timeout_ms: + await Engine.get_main_loop().create_timer(0.1).timeout + var elapsed := Time.get_ticks_msec() - start + var removed := filesystem.get_file_type(file_path) == "" + return {"removed": removed, "elapsed_ms": elapsed} + + +## Delete a res:// file (+companions) then targeted-deindex it. Returns the +## delete_res_file dict with "deindexed": bool merged in on success; on failure +## the dict is the unmodified delete_res_file error (no "deindexed" key). Folds +## the delete -> ensure_file_removed -> deindexed sequence shared by file/scene/ +## resource/script delete so each caller no longer repeats the success-guard + +## key write. delete_res_file is not a coroutine (not awaited); only the +## ensure_file_removed poll yields. +static func delete_res_file_and_deindex( + file_path: String, companions: Array = [".uid"], +) -> Dictionary: + var delete_result: Dictionary = delete_res_file(file_path, companions) + if delete_result.get("success", false): + var removal: Dictionary = await ensure_file_removed(file_path) + delete_result["deindexed"] = removal["removed"] + return delete_result + + +# -- Godot 4.3 SceneTreeEditor tooltip-timer UAF mitigation ------------------- + + +## True on Godot 4.3.x — the only engine line that needs the tooltip-timer disarm. +## 4.2 renders node tooltips synchronously (no deferred timer to strand); 4.4+ caches +## TreeItems across tree rebuilds (PR #99700) so the bound row outlives the churn. +## Pure (engine_ver injected, e.g. VersionUtils.get_engine_version_pair()) so the +## exact 4.3-only boundary is headless-unit-tested. The disarm is zero-cost, so the +## gate is version-only — no OS axis. +static func should_disarm_tooltip_uaf(engine_ver: String) -> bool: + return engine_ver == "4.3" + + +## Before a toolkit write of [param node]'s [param property], drop Godot 4.3's +## SceneTreeEditor tooltip-timer connections so the write cannot arm a use-after-free. +## No-op unless [param property] is [code]editor_description[/code] and the engine is +## 4.3.x (see [method should_disarm_tooltip_uaf]). +## [br] +## On 4.3, setting [code]editor_description[/code] emits +## [code]editor_description_changed[/code]; each connected SceneTreeEditor arms a 0.5s +## one-shot Timer bound to the node's current [code]TreeItem*[/code], and concurrent +## tree churn then runs [code]tree->clear()[/code] (no TreeItem cache before 4.4), +## freeing that row — so the timer fires on freed memory → editor SIGSEGV. Removing the +## SceneTreeEditor slots before the set means nothing arms. No reconnect is needed: the +## editor re-adds the connection with a fresh row bind and refreshes the tooltip +## synchronously on its next tree rebuild (scene_tree_editor.cpp:371-376), so no tooltip +## is lost. Only SceneTreeEditor slots are removed — a user script connected to the same +## public signal is left intact. +## [br] +## Call this immediately before the set with no [code]await[/code] in between; a yield +## would let tree churn re-add and re-arm a slot. A later human undo/redo replays the set +## outside this guard, exposing it exactly like any inspector edit — out of scope here +## (cured upstream in 4.4). Any new tool or extension that writes editor_description MUST +## call this (see docs/extending.md). +static func disarm_tooltip_uaf(node: Object, property: String) -> void: + if property != "editor_description": + return + if not should_disarm_tooltip_uaf(VersionUtils.get_engine_version_pair()): + return + for conn in node.get_signal_connection_list(&"editor_description_changed"): + var callable: Callable = conn["callable"] + var target: Object = callable.get_object() + if target != null and target.get_class() == "SceneTreeEditor": + node.disconnect(&"editor_description_changed", callable) + + +# -- File-create collision decision ------------------------------------------- + + +## Resolve the file-level idempotency collision decision shared by file creators +## (scene.create, asset.import, texture/sound.generate). Pure query: validates +## if_exists, checks destination existence, and returns the DECISION only — the +## caller owns its own early-return payload, its own write, and its own status +## string (`"replaced" if existed else "created"`). Centralising the *decision* +## (not the payloads) keeps each creator's emitted bytes — which legitimately +## differ (extra keys, replace side-effects, message wording) — under the +## caller's control while killing the duplicated validate->exists->branch logic. +## (Pure query — this returns data, changes nothing.) +## +## dest_path res:// destination (NOT path-guarded here — callers guard first) +## if_exists "return" | "fail" | "replace" +## +## Returns a DECISION dict: +## {valid: false} — if_exists was not a legal value; +## caller emits its own INVALID_PARAMS. +## {valid: true, existed: false, action: "create"} +## — no collision; caller writes, "created". +## {valid: true, existed: true, action: "return"} +## — idempotent no-op; caller emits its own +## "returned" success payload. +## {valid: true, existed: true, action: "fail"} — caller emits its own ALREADY_EXISTS error. +## {valid: true, existed: true, action: "replace"} +## — caller runs its own replace side-effects +## + write, status "replaced". +static func resolve_create_collision(dest_path: String, if_exists: String) -> Dictionary: + if if_exists not in ["return", "fail", "replace"]: + return {"valid": false} + var existed := FileAccess.file_exists(dest_path) + if not existed: + return {"valid": true, "existed": false, "action": "create"} + # Collision: the legal if_exists value IS the action verb for this case. + return {"valid": true, "existed": true, "action": if_exists} + + +# -- Batch partial-failure summary -------------------------------------------- + + +## Roll a per-entry results[] up into a top-level partial-failure summary so a +## caller (or LLM) that reads only the top of the response still sees that some +## entries failed, instead of having to inspect every entry. +## +## Mutates and returns the SAME `response` dict: when >=1 entry failed it ADDS +## `failed` (int count) + `hint` (String); when every entry succeeded it returns +## `response` UNCHANGED (no keys added) — so an all-success batch is byte-identical +## to before. Every pre-existing key (results, count, action, warning, …) is left +## exactly as the caller set it; this only ever ADDS the two summary keys. +## +## The failure predicate is shape-tolerant so one helper serves both batch +## conventions in use: an entry is a failure iff it is a Dictionary AND +## (success == false) OR (it has no `success` key but has an `error` key). That +## covers the {success: bool} shape (node.set_property batch) and the +## {status?, error?} shape with no success field (node.groups batch). +## +## Pure (no engine state) → a headless unit test pins it with hand-built dicts. +## (Shared response shaper, beside the other pure response/decision helpers here.) +static func summarize_batch(response: Dictionary, results_key := "results") -> Dictionary: + var entries: Array = response.get(results_key, []) + var total := entries.size() + var failed := 0 + for entry in entries: + if typeof(entry) != TYPE_DICTIONARY: + continue + var e := entry as Dictionary + if e.get("success") == false or (not e.has("success") and e.has("error")): + failed += 1 + if failed > 0: + response["failed"] = failed + response["hint"] = "%d of %d entries failed — inspect results[] for per-entry .error." % [failed, total] + return response + + +# -- Shared asset write + import-settle bracket ------------------------------- + + +## Write a generated/imported asset to a res:// path with the standard contract +## shared by asset.import, texture.generate, and sound.generate: path guard -> +## extension allowlist -> if_exists -> parent dir -> write -> import settle -> +## status/payload. The actual write is delegated to `write_fn` so each tool +## supplies only its own save call (raw bytes / Image.save_png / save_to_wav). +## +## dest_path res:// destination (path-guarded here) +## allowed_exts lowercase extensions this tool may write (e.g. ["png"]) +## if_exists "return" (idempotent no-op) | "fail" | "replace" +## wait_for_scan_ms import-settle timeout, [0, 30000] (0 disables the wait) +## method handler name, for error/dir context +## write_fn Callable(dest_path: String) -> Dictionary +## {} on success, or an MCPToolkitError.fail(...) dict on failure. +## known_class OPTIONAL. When non-empty, the caller GUARANTEES the saved +## asset's class (e.g. a generator that wrote a PNG knows it is +## a Texture2D). The class is then reported directly and the +## blocking import-settle poll is skipped (a single +## non-blocking update_file() still runs so the FS dock catches +## up). asset.import passes "" because the imported type is +## unknown until the editor runs the import. +## +## Returns the core success payload +## {status, path, class, warnings, elapsed_ms, success:true} (status is +## "created" | "replaced" | "returned"), or an MCPToolkitError dict +## (PATH_DENIED / INVALID_PATH / INVALID_PARAMS / ALREADY_EXISTS / PARENT_NOT_FOUND / +## WRITE_FAILED). Callers merge tool-specific fields into the returned dict on +## success (result.success == true). +static func write_asset_with_settle( + dest_path: String, + allowed_exts: PackedStringArray, + if_exists: String, + wait_for_scan_ms: int, + method: String, + write_fn: Callable, + known_class: String = "", +) -> Dictionary: + var guard := FileGuard.resolve_safe(dest_path) + if guard["error"] != null: + return MCPToolkitError.fail("PATH_DENIED", str(guard["reason"])) + + var extension := dest_path.get_extension().to_lower() + if not allowed_exts.has(extension): + return MCPToolkitError.fail("INVALID_PATH", + "extension '%s' not allowed for %s; expected: %s" % [ + extension, method, ", ".join(allowed_exts)]) + + var collision := resolve_create_collision(dest_path, if_exists) + if not collision["valid"]: + return MCPToolkitError.fail("INVALID_PARAMS", + "if_exists must be one of 'return', 'fail', 'replace' (got '%s')" % if_exists) + if wait_for_scan_ms < 0 or wait_for_scan_ms > 30000: + return MCPToolkitError.fail("INVALID_PARAMS", + "wait_for_scan_ms must be in [0, 30000] (got %d); 0 disables wait" % wait_for_scan_ms) + + var file_existed: bool = collision["existed"] + if file_existed: + match collision["action"]: + "return": + return MCPToolkitSuccess.ok({ + "status": "returned", "path": dest_path, + "class": known_class if known_class != "" else _file_class_or_null(dest_path), "warnings": [], "elapsed_ms": 0}) + "fail": + return MCPToolkitError.fail("ALREADY_EXISTS", + "file already exists at %s; use if_exists:'replace' to overwrite or if_exists:'return' for idempotent no-op" % dest_path) + "replace": + pass + + var dir_result := ensure_parent_dir(dest_path, method) + if dir_result.has("error"): + return dir_result + + var write_result: Variant = write_fn.call(dest_path) + if typeof(write_result) == TYPE_DICTIONARY and (write_result as Dictionary).has("error"): + return write_result + + var warnings: Array[String] = [] + var file_class: Variant = null + var elapsed_ms := 0 + + if known_class != "" and wait_for_scan_ms <= 0: + # Generator default path: the class is known by construction, so skip the + # blocking import-settle poll entirely. Fire one non-blocking update_file() + # so the FS dock catches up; the asset is usable regardless (resource_load + # imports on demand). No "did not index" warning — we never waited. + var fs := EditorInterface.get_resource_filesystem() + if fs != null: + fs.update_file(dest_path) + file_class = known_class + else: + # asset.import (type unknown until the editor imports → must settle via the + # FS), OR a generator that explicitly opted into a wait (wait_for_scan_ms > 0). + var index_result := await ensure_file_indexed(dest_path, wait_for_scan_ms) + elapsed_ms = int(index_result["elapsed_ms"]) + if not index_result["indexed"]: + warnings.append( + "EditorFileSystem did not index %s within %dms — call editor.wait_for_idle to finish" % [dest_path, wait_for_scan_ms]) + if known_class != "": + file_class = known_class # generator: the constructed class is authoritative + elif str(index_result["file_class"]) != "": + file_class = index_result["file_class"] + + var status := "replaced" if file_existed else "created" + return MCPToolkitSuccess.ok({ + "status": status, + "path": dest_path, + "class": file_class, + "warnings": warnings, + "elapsed_ms": elapsed_ms, + }) + + +## EditorFileSystem class name for an already-indexed file, or null if unknown. +static func _file_class_or_null(file_path: String) -> Variant: + var filesystem := EditorInterface.get_resource_filesystem() + if filesystem == null: + return null + var file_type := filesystem.get_file_type(file_path) + return file_type if file_type != "" else null diff --git a/addons/godot_mcp_toolkit/commands/editor_helpers.gd.uid b/addons/godot_mcp_toolkit/commands/editor_helpers.gd.uid new file mode 100644 index 0000000..ff7e7ec --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/editor_helpers.gd.uid @@ -0,0 +1 @@ +uid://cnoe7w3aw4hdp diff --git a/addons/godot_mcp_toolkit/commands/file_commands.gd b/addons/godot_mcp_toolkit/commands/file_commands.gd new file mode 100644 index 0000000..fe553f9 --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/file_commands.gd @@ -0,0 +1,65 @@ +@tool +extends RefCounted +## file.* command handlers — generic file deletion for any res:// path. + +const Modules := preload("res://addons/godot_mcp_toolkit/core/modules.gd") +const FileGuard = Modules.FileGuard +const Helpers = Modules.CommandHelpers + +const _TAB_CLOSE_NOISE_HINT := "Closing a non-active scene tab may produce a _set_main_scene_state error in the editor console. This is benign Godot engine noise — safe to ignore." + + +static func register(registry: MCPToolkitCommandRegistry, _server: Node) -> void: + registry.add("file.delete", func(parameters: Dictionary) -> Dictionary: + return await _cmd_file_delete(parameters) + , MCPToolkitCommandOptions.new().mark_scene_independent()) + + +# -- Commands ----------------------------------------------------------------- + + +static func _cmd_file_delete(parameters: Dictionary) -> Dictionary: + var file_path := str(parameters.get("file_path", "")) + if file_path.is_empty(): + return MCPToolkitError.fail("INVALID_PARAMS", "missing file_path") + var guard := FileGuard.resolve_safe(file_path) + if guard["error"] != null: + return MCPToolkitError.fail("PATH_DENIED", str(guard["reason"])) + if file_path.begins_with("res://addons/godot_mcp_toolkit/"): + return MCPToolkitError.fail("PATH_DENIED", + "cannot delete files inside the MCP toolkit plugin directory") + if not FileAccess.file_exists(file_path): + return MCPToolkitError.fail("NOT_FOUND", "file not found: %s" % file_path, MCPToolkitError.HINT_FILE_PATH) + + # For scene files (.tscn/.scn), attempt to close the editor tab first. + var tab_closed := false + var tab_result := {} + var warnings: Array[String] = [] + var ext := file_path.get_extension().to_lower() + var is_scene := (ext == "tscn" or ext == "scn") + + if is_scene: + tab_result = await Helpers.close_scene_tab_safe(file_path) + tab_closed = tab_result.get("closed", false) + if not tab_closed: + var reason := str(tab_result.get("reason", "")) + if reason == "no_api": + # 4.2–4.4: no close API. Block active-scene deletion (Ctrl+S + # would silently recreate the file). Non-active tabs proceed + # with a phantom warning. + var edited_root := Helpers.get_edited_root() + if edited_root != null and edited_root.scene_file_path == file_path: + return MCPToolkitError.fail("EDITED_SCENE", + "cannot delete the currently-edited scene %s on Godot 4.2-4.4 (no programmatic tab-close API); close the scene tab manually, then retry file_delete" % file_path) + warnings.append( + "phantom tab: scene tab for %s remains open; Godot 4.2-4.4 has no API to close tabs — it will vanish on editor restart or manual close" % file_path) + + var delete_result: Dictionary = await Helpers.delete_res_file_and_deindex( + file_path, [".uid", ".import"]) + if is_scene: + delete_result["tab_closed"] = tab_closed + if tab_closed and tab_result.get("switched", false): + delete_result["hint"] = _TAB_CLOSE_NOISE_HINT + if not warnings.is_empty(): + delete_result["warnings"] = warnings + return delete_result diff --git a/addons/godot_mcp_toolkit/commands/file_commands.gd.uid b/addons/godot_mcp_toolkit/commands/file_commands.gd.uid new file mode 100644 index 0000000..c429a82 --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/file_commands.gd.uid @@ -0,0 +1 @@ +uid://1s4drvr2wla1 diff --git a/addons/godot_mcp_toolkit/commands/folder_commands.gd b/addons/godot_mcp_toolkit/commands/folder_commands.gd new file mode 100644 index 0000000..e65ab14 --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/folder_commands.gd @@ -0,0 +1,231 @@ +@tool +extends RefCounted +## folder.* command handlers — create and delete directories under res://. + +const Modules := preload("res://addons/godot_mcp_toolkit/core/modules.gd") +const FileGuard = Modules.FileGuard +const Helpers = Modules.CommandHelpers + + +static func register(registry: MCPToolkitCommandRegistry, _server: Node) -> void: + registry.add("folder.create", func(parameters: Dictionary) -> Dictionary: + return _cmd_folder_create(parameters) + , MCPToolkitCommandOptions.new().mark_scene_independent()) + registry.add("folder.delete", func(parameters: Dictionary) -> Dictionary: + return await _cmd_folder_delete(parameters) + , MCPToolkitCommandOptions.new().mark_scene_independent()) + + +# -- Commands ----------------------------------------------------------------- + + +static func _cmd_folder_create(parameters: Dictionary) -> Dictionary: + var err = MCPToolkitError.require(parameters, ["path"]) + if err != null: + return err + var path := str(parameters.get("path", "")) + var guard := FileGuard.resolve_safe(path) + if guard["error"] != null: + return MCPToolkitError.fail("PATH_DENIED", str(guard["reason"])) + var pre_existed := DirAccess.dir_exists_absolute(path) + var error := DirAccess.make_dir_recursive_absolute(path) + if error != OK: + return MCPToolkitError.fail("CREATE_DIR_FAILED", + "DirAccess.make_dir_recursive_absolute returned %d (path=%s)" % [error, path]) + var status := "returned" if pre_existed else "created" + return MCPToolkitSuccess.ok({"status": status, "path": path}) + + +static func _cmd_folder_delete(parameters: Dictionary) -> Dictionary: + var err = MCPToolkitError.require(parameters, ["path"]) + if err != null: + return err + var path := str(parameters.get("path", "")) + var recursive := bool(parameters.get("recursive", false)) + var guard := FileGuard.resolve_safe(path) + if guard["error"] != null: + return MCPToolkitError.fail("PATH_DENIED", str(guard["reason"])) + + if path == "res://" or path == "res:///" or path.get_base_dir() == "": + return MCPToolkitError.fail("FOLDER_PROTECTED", + "cannot delete the project root res://; narrow the path") + + var normalized := path + if normalized.ends_with("/"): + normalized = normalized.substr(0, normalized.length() - 1) + if normalized == "res://addons" or normalized == "res://addons/godot_mcp_toolkit": + return MCPToolkitError.fail("FOLDER_PROTECTED", + "cannot delete res://addons or the toolkit plugin directory (%s); agent cannot remove its own host" % normalized) + if not DirAccess.dir_exists_absolute(path): + return MCPToolkitError.fail("NOT_FOUND", "no folder at %s" % path) + var normalized_with_slash := normalized + "/" + + # Collect open scene tabs inside vs outside the target folder. + var open_scenes := EditorInterface.get_open_scenes() + var inside_scenes: Array[String] = [] + var outside_scenes: Array[String] = [] + for scene_path in open_scenes: + var sp := str(scene_path) + if sp == normalized or sp.begins_with(normalized_with_slash): + inside_scenes.append(sp) + else: + outside_scenes.append(sp) + + var edited := Helpers.get_edited_root() + var active_path := "" + if edited != null: + active_path = str(edited.scene_file_path) + var active_inside := not active_path.is_empty() and (active_path == normalized or active_path.begins_with(normalized_with_slash)) + + # Scene tab handling strategy: + # - Exactly 1 scene inside + on 4.5+: close it via helper (single + # open+close cycle is safe; avoids phantom tab entirely). + # - Multiple scenes inside: cannot close in a loop (deferred-queue + # crash). Switch active away, return stale_tabs for agent follow-up + # via scene.close (MCP round-trip provides sequencing). + # - On 4.2–4.4: no close API — always switch-away + stale_tabs. + var stale_tabs: Array[String] = [] + var warnings: Array[String] = [] + var single_closed := false + # close_scene is 4.5+; on 4.2–4.4 there is no API to close scene tabs, so the + # stale-tab follow-up hint must not steer to the (absent) scene_close tool. + var can_close := EditorInterface.has_method("close_scene") + + if inside_scenes.size() == 1 and can_close: + # Single scene — safe to close via helper (handles active or not). + var tab_result := await Helpers.close_scene_tab_safe(inside_scenes[0]) + single_closed = tab_result.get("closed", false) + if not single_closed: + # no_api shouldn't happen (we checked has_method), but be safe. + stale_tabs = inside_scenes + elif inside_scenes.size() > 0: + # Multiple scenes (or 4.2–4.4): switch active away, list phantoms. + if active_inside: + if outside_scenes.is_empty(): + return MCPToolkitError.fail("PATH_IN_USE", + "all open scene tabs are inside %s; open a scene outside the folder first via scene_open, then retry folder_delete" % path) + if not await Helpers.open_scene_deferred(outside_scenes[0]): + return MCPToolkitError.fail("TIMEOUT", + "could not switch active scene away from %s — filesystem scanning; retry shortly" % path) + stale_tabs = inside_scenes + if not can_close: + warnings.append( + "phantom tabs: Godot 4.2-4.4 has no API to close scene tabs — %d tab(s) inside %s will remain as phantoms until editor restart" % [inside_scenes.size(), path]) + + var script_editor := EditorInterface.get_script_editor() + if script_editor != null: + for open_script in script_editor.get_open_scripts(): + if not (open_script is Resource): + continue + var resource_path := str((open_script as Resource).resource_path) + if resource_path.is_empty(): + continue + if resource_path == normalized or resource_path.begins_with(normalized_with_slash): + return MCPToolkitError.fail("PATH_IN_USE", + "folder %s contains open script %s; close the script editor tab manually (no programmatic close API for script tabs), then retry folder_delete" % [ + path, resource_path]) + + var directory := DirAccess.open(path) + if directory == null: + return MCPToolkitError.fail("INTERNAL", + "DirAccess.open(%s) returned null" % path) + var file_count := directory.get_files().size() + var subdir_count := directory.get_directories().size() + if (file_count + subdir_count) > 0 and not recursive: + return MCPToolkitError.fail("DIR_NOT_EMPTY", + "folder %s is not empty (contains %d files, %d subdirs); pass recursive:true to delete contents" % [ + path, file_count, subdir_count]) + + var files_deleted := 0 + var dirs_deleted := 0 + if recursive and (file_count + subdir_count) > 0: + var result := _folder_delete_recursive(path) + files_deleted = int(result.get("files", 0)) + dirs_deleted = int(result.get("dirs", 0)) + if not bool(result.get("success", false)): + return MCPToolkitError.fail("DELETE_FAILED", str(result.get("error", "unknown"))) + + var parent_path := path.get_base_dir() + var parent_dir := DirAccess.open(parent_path) + if parent_dir == null: + return MCPToolkitError.fail("INTERNAL", + "DirAccess.open(%s) returned null" % parent_path) + var top_remove := parent_dir.remove(path.get_file()) + if top_remove != OK: + return MCPToolkitError.fail("DELETE_FAILED", + "DirAccess.remove returned %d (path=%s)" % [top_remove, path]) + if recursive and (file_count + subdir_count) > 0: + push_warning("[MCPTools] folder.delete recursive %s (%d files, %d subdirs)" % [ + path, files_deleted, dirs_deleted]) + # The target folder itself was just removed above — count it alongside any + # nested subdirectories the recursive pass deleted. Previously omitted, so a + # flat folder reported directories_deleted:0 despite the folder being gone. + dirs_deleted += 1 + # Targeted deindex: update_file() on a directory path is a no-op in most + # Godot versions, so fall back to scan() for folder removal. Folder deletes + # are rare and the scan cost is acceptable. + var removal := await Helpers.ensure_file_removed(path) + var result := { + "path": path, + "recursive": recursive, + "files_deleted": files_deleted, + "directories_deleted": dirs_deleted, + "deindexed": removal["removed"], + } + if single_closed: + result["tab_closed"] = inside_scenes[0] + if not stale_tabs.is_empty(): + result["stale_tabs"] = stale_tabs + if can_close: + result["hint"] = "Phantom scene tabs remain for %d file(s) inside the deleted folder. Close them one at a time via scene_close. Note: each scene_close may produce a _set_main_scene_state error in the editor console — this is benign Godot engine noise, safe to ignore." % stale_tabs.size() + else: + result["hint"] = "Phantom scene tabs remain for %d file(s) inside the deleted folder. Godot 4.2–4.4 has no API to close scene tabs — restart the editor to clear them." % stale_tabs.size() + if active_inside and not single_closed: + result["switched_to"] = outside_scenes[0] + if not warnings.is_empty(): + result["warnings"] = warnings + return MCPToolkitSuccess.ok(result) + + +# -- Recursive delete helper -------------------------------------------------- + + +static func _folder_delete_recursive(path: String) -> Dictionary: + var directory := DirAccess.open(path) + if directory == null: + return {"files": 0, "dirs": 0, "success": false, + "error": "DirAccess.open(%s) returned null" % path} + var files_removed := 0 + var dirs_removed := 0 + for file_name in directory.get_files(): + if file_name.ends_with(".uid"): + continue + var full_res_path := path + "/" + file_name + var uid: int = ResourceLoader.get_resource_uid(full_res_path) + var remove_error := directory.remove(file_name) + if remove_error != OK: + return {"files": files_removed, "dirs": dirs_removed, "success": false, + "error": "DirAccess.remove %s/%s returned %d" % [path, file_name, remove_error]} + files_removed += 1 + var uid_companion := file_name + ".uid" + if directory.file_exists(uid_companion): + directory.remove(uid_companion) + if uid != -1 and ResourceUID.has_id(uid): + ResourceUID.remove_id(uid) + for file_name in directory.get_files(): + if file_name.ends_with(".uid"): + directory.remove(file_name) + for sub_name in directory.get_directories(): + var sub_path := path + "/" + sub_name + var sub_result := _folder_delete_recursive(sub_path) + files_removed += int(sub_result.get("files", 0)) + dirs_removed += int(sub_result.get("dirs", 0)) + if not bool(sub_result.get("success", false)): + return {"files": files_removed, "dirs": dirs_removed, "success": false, + "error": str(sub_result.get("error", "unknown"))} + var remove_sub_error := directory.remove(sub_name) + if remove_sub_error != OK: + return {"files": files_removed, "dirs": dirs_removed, "success": false, + "error": "DirAccess.remove (subdir) %s returned %d" % [sub_path, remove_sub_error]} + dirs_removed += 1 + return {"files": files_removed, "dirs": dirs_removed, "success": true, "error": ""} diff --git a/addons/godot_mcp_toolkit/commands/folder_commands.gd.uid b/addons/godot_mcp_toolkit/commands/folder_commands.gd.uid new file mode 100644 index 0000000..fa0f351 --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/folder_commands.gd.uid @@ -0,0 +1 @@ +uid://b3fwykd5l8fvp diff --git a/addons/godot_mcp_toolkit/commands/input_map_commands.gd b/addons/godot_mcp_toolkit/commands/input_map_commands.gd new file mode 100644 index 0000000..408ba36 --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/input_map_commands.gd @@ -0,0 +1,276 @@ +@tool +extends RefCounted +## input_map.* command handlers — action (add/remove) and event (bind/unbind). + +const Modules := preload("res://addons/godot_mcp_toolkit/core/modules.gd") + +const BUILTIN_UI_ACTIONS: Array[String] = [ + "ui_accept", "ui_cancel", "ui_focus_next", "ui_focus_prev", + "ui_up", "ui_down", "ui_left", "ui_right", + "ui_page_up", "ui_page_down", "ui_home", "ui_end", + "ui_cut", "ui_copy", "ui_paste", "ui_undo", "ui_redo", + "ui_text_completion_query", "ui_text_completion_accept", + "ui_text_completion_replace", + "ui_text_newline", "ui_text_newline_blank", "ui_text_newline_above", + "ui_text_backspace", "ui_text_backspace_word", + "ui_text_backspace_all_to_left", + "ui_text_delete", "ui_text_delete_word", + "ui_text_delete_all_to_right", + "ui_text_caret_left", "ui_text_caret_word_left", + "ui_text_caret_right", "ui_text_caret_word_right", + "ui_text_caret_up", "ui_text_caret_down", + "ui_text_caret_line_start", "ui_text_caret_line_end", + "ui_text_caret_page_up", "ui_text_caret_page_down", + "ui_text_caret_document_start", "ui_text_caret_document_end", + "ui_text_caret_add_below", "ui_text_caret_add_above", + "ui_text_scroll_up", "ui_text_scroll_down", + "ui_text_select_all", "ui_text_select_word_under_caret", + "ui_text_add_selection_for_next_occurrence", + "ui_text_clear_carets_and_selection", + "ui_text_toggle_insert_mode", "ui_menu", "ui_text_submit", + "ui_graph_duplicate", "ui_graph_delete", + "ui_filedialog_up_one_level", "ui_filedialog_refresh", + "ui_filedialog_show_hidden", + "ui_swap_input_direction", +] + + +static func register(registry: MCPToolkitCommandRegistry, _server: Node) -> void: + registry.add("input_map.action", func(parameters: Dictionary) -> Dictionary: + return _cmd_input_map_action(parameters) + , MCPToolkitCommandOptions.new().mark_scene_independent()) + registry.add("input_map.event", func(parameters: Dictionary) -> Dictionary: + return _cmd_input_map_event(parameters) + , MCPToolkitCommandOptions.new().mark_scene_independent()) + + +# -- Input event helpers ------------------------------------------------------ + + +static func _build_input_event(event: Variant) -> Variant: + if typeof(event) != TYPE_DICTIONARY: + return {"code": "INVALID_PARAMS", "error": "event must be an object"} + var event_type := str(event.get("type", "")) + match event_type: + "key": + var raw_keycode = event.get("keycode", "") + var resolved_keycode := 0 + if typeof(raw_keycode) == TYPE_STRING: + var keycode_str: String = raw_keycode + if keycode_str.begins_with("KEY_"): + keycode_str = keycode_str.substr(4) + resolved_keycode = OS.find_keycode_from_string(keycode_str) + if resolved_keycode == 0: + return {"code": "INVALID_PARAMS", + "error": "unknown keycode '%s'; use symbolic names like 'SPACE' / 'A' / 'F1' or raw ints" % raw_keycode} + elif typeof(raw_keycode) == TYPE_INT or typeof(raw_keycode) == TYPE_FLOAT: + resolved_keycode = int(raw_keycode) + else: + return {"code": "INVALID_PARAMS", + "error": "event.keycode must be a string symbolic name or int"} + var key_event := InputEventKey.new() + key_event.physical_keycode = resolved_keycode + key_event.shift_pressed = bool(event.get("shift", false)) + key_event.ctrl_pressed = bool(event.get("ctrl", false)) + key_event.alt_pressed = bool(event.get("alt", false)) + key_event.meta_pressed = bool(event.get("meta", false)) + return key_event + "mouse_button": + var mouse_event := InputEventMouseButton.new() + mouse_event.button_index = int(event.get("button_index", 1)) + mouse_event.pressed = bool(event.get("pressed", true)) + return mouse_event + "joypad_button": + var joypad_button_event := InputEventJoypadButton.new() + joypad_button_event.button_index = int(event.get("button_index", 0)) + joypad_button_event.device = int(event.get("device", -1)) + return joypad_button_event + "joypad_motion": + var joypad_motion_event := InputEventJoypadMotion.new() + joypad_motion_event.axis = int(event.get("axis", 0)) + joypad_motion_event.axis_value = float(event.get("axis_value", 0.0)) + joypad_motion_event.device = int(event.get("device", -1)) + return joypad_motion_event + _: + return {"code": "INVALID_PARAMS", + "error": "event.type must be one of 'key' | 'mouse_button' | 'joypad_button' | 'joypad_motion' (got %s)" % event_type} + + +static func _serialise_input_event(event: InputEvent) -> Dictionary: + if event is InputEventKey: + var kc: int = int((event as InputEventKey).physical_keycode) + return { + "type": "key", + "keycode": kc, + "keycode_name": OS.get_keycode_string(kc), + "shift": (event as InputEventKey).shift_pressed, + "ctrl": (event as InputEventKey).ctrl_pressed, + "alt": (event as InputEventKey).alt_pressed, + "meta": (event as InputEventKey).meta_pressed, + } + if event is InputEventMouseButton: + return { + "type": "mouse_button", + "button_index": int((event as InputEventMouseButton).button_index), + "pressed": (event as InputEventMouseButton).pressed, + } + if event is InputEventJoypadButton: + return { + "type": "joypad_button", + "button_index": int((event as InputEventJoypadButton).button_index), + "device": int((event as InputEventJoypadButton).device), + } + if event is InputEventJoypadMotion: + return { + "type": "joypad_motion", + "axis": int((event as InputEventJoypadMotion).axis), + "axis_value": float((event as InputEventJoypadMotion).axis_value), + "device": int((event as InputEventJoypadMotion).device), + } + return {"type": "unknown", "class": event.get_class()} + + +static func _input_events_equivalent( + event_a: InputEvent, event_b: InputEvent, +) -> bool: + if event_a == null or event_b == null: + return false + if event_a.get_class() != event_b.get_class(): + return false + if event_a is InputEventKey and event_b is InputEventKey: + var key_a := event_a as InputEventKey + var key_b := event_b as InputEventKey + return key_a.physical_keycode == key_b.physical_keycode \ + and key_a.shift_pressed == key_b.shift_pressed \ + and key_a.ctrl_pressed == key_b.ctrl_pressed \ + and key_a.alt_pressed == key_b.alt_pressed \ + and key_a.meta_pressed == key_b.meta_pressed + if event_a is InputEventMouseButton and event_b is InputEventMouseButton: + var mouse_a := event_a as InputEventMouseButton + var mouse_b := event_b as InputEventMouseButton + return mouse_a.button_index == mouse_b.button_index \ + and mouse_a.pressed == mouse_b.pressed + if event_a is InputEventJoypadButton and event_b is InputEventJoypadButton: + var joypad_a := event_a as InputEventJoypadButton + var joypad_b := event_b as InputEventJoypadButton + return joypad_a.button_index == joypad_b.button_index \ + and joypad_a.device == joypad_b.device + if event_a is InputEventJoypadMotion and event_b is InputEventJoypadMotion: + var motion_a := event_a as InputEventJoypadMotion + var motion_b := event_b as InputEventJoypadMotion + return motion_a.axis == motion_b.axis \ + and motion_a.device == motion_b.device \ + and is_equal_approx(motion_a.axis_value, motion_b.axis_value) + return false + + +static func _persist_input_action(action: String, deadzone: float) -> void: + var events: Array = [] + for event in InputMap.action_get_events(action): + events.append(event) + ProjectSettings.set_setting("input/" + action, { + "deadzone": deadzone, + "events": events, + }) + var error := ProjectSettings.save() + if error != OK: + push_warning( + "[MCPServer] ProjectSettings.save after input_map mutation failed (err %d, action=%s); change is in-memory only" % [ + error, action]) + + +# -- Commands ----------------------------------------------------------------- + + +static func _cmd_input_map_action(parameters: Dictionary) -> Dictionary: + var action := str(parameters.get("action", "")) + if not (action in ["add", "remove"]): + return MCPToolkitError.fail("INVALID_PARAMS", + "action must be 'add' or 'remove' (got '%s')" % action) + var action_name := str(parameters.get("name", "")) + if action_name.is_empty(): + return MCPToolkitError.fail("INVALID_PARAMS", "name must be a non-empty string") + if action == "add": + var deadzone_raw = parameters.get("deadzone", 0.5) + var deadzone := float(deadzone_raw) \ + if (typeof(deadzone_raw) == TYPE_FLOAT or typeof(deadzone_raw) == TYPE_INT) else 0.5 + if deadzone < 0.0 or deadzone > 1.0: + return MCPToolkitError.fail("INVALID_PARAMS", + "deadzone must be in [0.0, 1.0] (got %f)" % deadzone) + if InputMap.has_action(action_name): + return MCPToolkitSuccess.ok({ + "status": "returned", + "name": action_name, + "deadzone": InputMap.action_get_deadzone(action_name), + }) + InputMap.add_action(action_name, deadzone) + _persist_input_action(action_name, deadzone) + return MCPToolkitSuccess.ok({ + "status": "created", + "name": action_name, + "deadzone": deadzone, + }) + else: + if not InputMap.has_action(action_name): + return MCPToolkitError.fail("NOT_FOUND", "no action '%s'" % action_name) + if action_name in BUILTIN_UI_ACTIONS: + return MCPToolkitError.fail("INVALID_PARAMS", + "refusing to remove built-in UI action '%s'; use input_map.event to unbind its events instead" % action_name) + InputMap.erase_action(action_name) + if ProjectSettings.has_setting("input/" + action_name): + ProjectSettings.clear("input/" + action_name) + var error := ProjectSettings.save() + if error != OK: + push_warning( + "[MCPServer] ProjectSettings.save after input_map.action remove failed (err %d, action=%s)" % [ + error, action_name]) + return MCPToolkitSuccess.ok({"name": action_name}) + + +static func _cmd_input_map_event(parameters: Dictionary) -> Dictionary: + var action := str(parameters.get("action", "")) + if not (action in ["bind", "unbind"]): + return MCPToolkitError.fail("INVALID_PARAMS", + "action must be 'bind' or 'unbind' (got '%s')" % action) + var action_name := str(parameters.get("name", "")) + if action_name.is_empty(): + return MCPToolkitError.fail("INVALID_PARAMS", "name must be a non-empty string") + if not InputMap.has_action(action_name): + return MCPToolkitError.fail("NOT_FOUND", + "no action '%s'; call input_map.action with action='add' first" % action_name) + var built: Variant = _build_input_event(parameters.get("event", null)) + if typeof(built) == TYPE_DICTIONARY and built.has("error"): + return MCPToolkitError.fail(str(built["code"]), str(built["error"])) + var event: InputEvent = built + if action == "bind": + for existing in InputMap.action_get_events(action_name): + if _input_events_equivalent(existing, event): + return MCPToolkitSuccess.ok({ + "status": "returned", + "name": action_name, + "event": _serialise_input_event(existing), + }) + InputMap.action_add_event(action_name, event) + _persist_input_action(action_name, InputMap.action_get_deadzone(action_name)) + return MCPToolkitSuccess.ok({ + "status": "created", + "name": action_name, + "event": _serialise_input_event(event), + }) + else: + var existing_events := InputMap.action_get_events(action_name) + var matched: InputEvent = null + for existing in existing_events: + if _input_events_equivalent(existing, event): + matched = existing + break + if matched == null: + return MCPToolkitError.fail("NOT_FOUND", + "no matching event on action '%s' (found %d events)" % [ + action_name, existing_events.size()]) + InputMap.action_erase_event(action_name, matched) + _persist_input_action(action_name, InputMap.action_get_deadzone(action_name)) + return MCPToolkitSuccess.ok({ + "name": action_name, + "event": _serialise_input_event(matched), + }) diff --git a/addons/godot_mcp_toolkit/commands/input_map_commands.gd.uid b/addons/godot_mcp_toolkit/commands/input_map_commands.gd.uid new file mode 100644 index 0000000..d5d0d3d --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/input_map_commands.gd.uid @@ -0,0 +1 @@ +uid://cevcwp6fpsfxk diff --git a/addons/godot_mcp_toolkit/commands/meta_commands.gd b/addons/godot_mcp_toolkit/commands/meta_commands.gd new file mode 100644 index 0000000..534ff95 --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/meta_commands.gd @@ -0,0 +1,35 @@ +@tool +extends RefCounted +## meta.* transport-level commands — server-side limit overrides. + + +static func register(registry: MCPToolkitCommandRegistry) -> void: + registry.add("meta.set_limits", func(parameters: Dictionary) -> Dictionary: + return _cmd_set_limits(parameters) + , MCPToolkitCommandOptions.new().mark_scene_independent()) + + +## The sanctioned writer for `mcp_toolkit/limits/*` — `project.set_setting` +## deliberately refuses that prefix, routing limit changes here so the floor +## clamps are always applied (script/save read caps >= 64 KB, ws_buffer >= 256 KB). +static func _cmd_set_limits(parameters: Dictionary) -> Dictionary: + var script_cap = parameters.get("script_read_cap_kb", null) + var save_cap = parameters.get("save_read_cap_kb", null) + var ws_buf = parameters.get("ws_buffer_kb", null) + if script_cap != null: + ProjectSettings.set_setting("mcp_toolkit/limits/script_read_cap_kb", + maxi(int(script_cap), 64)) + if save_cap != null: + ProjectSettings.set_setting("mcp_toolkit/limits/save_read_cap_kb", + maxi(int(save_cap), 64)) + if ws_buf != null: + ProjectSettings.set_setting("mcp_toolkit/limits/ws_buffer_kb", + maxi(int(ws_buf), 256)) + return MCPToolkitSuccess.ok({ + "script_read_cap_kb": int(ProjectSettings.get_setting( + "mcp_toolkit/limits/script_read_cap_kb", 256)), + "save_read_cap_kb": int(ProjectSettings.get_setting( + "mcp_toolkit/limits/save_read_cap_kb", 256)), + "ws_buffer_kb": int(ProjectSettings.get_setting( + "mcp_toolkit/limits/ws_buffer_kb", 1024)), + }) diff --git a/addons/godot_mcp_toolkit/commands/meta_commands.gd.uid b/addons/godot_mcp_toolkit/commands/meta_commands.gd.uid new file mode 100644 index 0000000..422e44f --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/meta_commands.gd.uid @@ -0,0 +1 @@ +uid://cn08ijvjyr8o2 diff --git a/addons/godot_mcp_toolkit/commands/navigation_commands.gd b/addons/godot_mcp_toolkit/commands/navigation_commands.gd new file mode 100644 index 0000000..f28ba54 --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/navigation_commands.gd @@ -0,0 +1,179 @@ +@tool +extends RefCounted +## navigation.* command handlers — NavigationRegion2D polygon editing + baking. + +const Modules := preload("res://addons/godot_mcp_toolkit/core/modules.gd") +const Helpers = Modules.CommandHelpers + + +static func register(registry: MCPToolkitCommandRegistry, _server: Node) -> void: + registry.add("navigation.edit_polygon", func(parameters: Dictionary) -> Dictionary: + return _cmd_edit_polygon(parameters) + , MCPToolkitCommandOptions.new()) + + +static func _cmd_edit_polygon(parameters: Dictionary) -> Dictionary: + var err = MCPToolkitError.require(parameters, ["node_path", "action"]) + if err != null: + return err + + var node_path := str(parameters["node_path"]) + var action := str(parameters["action"]) + + var edited_scene := EditorInterface.get_edited_scene_root() + if edited_scene == null: + return MCPToolkitError.fail("NO_SCENE", "No scene is currently open in the editor") + + node_path = Helpers.normalize_editor_path(node_path) + var node := edited_scene.get_node_or_null(NodePath(node_path)) + if node == null: + return MCPToolkitError.fail("NOT_FOUND", "Node not found: " + node_path) + + if not (node is NavigationRegion2D): + return MCPToolkitError.fail("INVALID_CLASS", + "Expected NavigationRegion2D, got " + node.get_class()) + + var region := node as NavigationRegion2D + + # Ensure the region has a NavigationPolygon resource + var nav_poly := region.navigation_polygon + if nav_poly == null: + nav_poly = NavigationPolygon.new() + region.navigation_polygon = nav_poly + + match action: + "set": + return _action_set(region, parameters, nav_poly) + "add_outline": + return _action_add_outline(region, parameters, nav_poly) + "remove_outline": + return _action_remove_outline(region, parameters, nav_poly) + "clear": + return _action_clear(region, nav_poly) + "bake": + return _action_bake(region, nav_poly) + _: + return MCPToolkitError.fail("INVALID_PARAMS", + "Unknown action '%s'; valid actions: set, add_outline, remove_outline, clear, bake" % action) + + +static func _action_set(region: NavigationRegion2D, parameters: Dictionary, nav_poly: NavigationPolygon) -> Dictionary: + var outlines = parameters.get("outlines", null) + if outlines == null or typeof(outlines) != TYPE_ARRAY or outlines.size() == 0: + return MCPToolkitError.fail("INVALID_PARAMS", + "'outlines' must be a non-empty Array of outline arrays") + + # Capture old state for undo. + var old_poly := nav_poly.duplicate() as NavigationPolygon + nav_poly.clear_outlines() + for outline_data in outlines: + if typeof(outline_data) != TYPE_ARRAY: + continue + var packed := PackedVector2Array() + for pt in outline_data: + if typeof(pt) == TYPE_DICTIONARY: + packed.append(Vector2(float(pt.get("x", 0)), float(pt.get("y", 0)))) + if packed.size() >= 3: + nav_poly.add_outline(packed) + + _wrap_undo_redo(region, nav_poly, old_poly, "navigation.edit_polygon set") + return _make_result(nav_poly) + + +static func _action_add_outline(region: NavigationRegion2D, parameters: Dictionary, nav_poly: NavigationPolygon) -> Dictionary: + var outline_data = parameters.get("outline", null) + if outline_data == null or typeof(outline_data) != TYPE_ARRAY: + return MCPToolkitError.fail("INVALID_PARAMS", "'outline' must be an Array of {x, y} points") + + var packed := PackedVector2Array() + for pt in outline_data: + if typeof(pt) == TYPE_DICTIONARY: + packed.append(Vector2(float(pt.get("x", 0)), float(pt.get("y", 0)))) + + if packed.size() < 3: + return MCPToolkitError.fail("INVALID_PARAMS", "Outline must have at least 3 points") + + var old_poly := nav_poly.duplicate() as NavigationPolygon + nav_poly.add_outline(packed) + + _wrap_undo_redo(region, nav_poly, old_poly, "navigation.edit_polygon add_outline") + return _make_result(nav_poly) + + +static func _action_remove_outline(region: NavigationRegion2D, parameters: Dictionary, nav_poly: NavigationPolygon) -> Dictionary: + var index = parameters.get("index", null) + if index == null: + return MCPToolkitError.fail("INVALID_PARAMS", "'index' is required for remove_outline") + + var idx := int(index) + if idx < 0 or idx >= nav_poly.get_outline_count(): + return MCPToolkitError.fail("INVALID_PARAMS", + "Outline index %d out of range (0..%d)" % [idx, nav_poly.get_outline_count() - 1]) + + var old_poly := nav_poly.duplicate() as NavigationPolygon + nav_poly.remove_outline(idx) + + _wrap_undo_redo(region, nav_poly, old_poly, "navigation.edit_polygon remove_outline") + return _make_result(nav_poly) + + +static func _action_clear(region: NavigationRegion2D, nav_poly: NavigationPolygon) -> Dictionary: + var old_poly := nav_poly.duplicate() as NavigationPolygon + nav_poly.clear_outlines() + nav_poly.clear_polygons() + + _wrap_undo_redo(region, nav_poly, old_poly, "navigation.edit_polygon clear") + return MCPToolkitSuccess.ok({"outline_count": 0, "vertex_count": 0}) + + +static func _action_bake(region: NavigationRegion2D, nav_poly: NavigationPolygon) -> Dictionary: + if nav_poly.get_outline_count() == 0: + return MCPToolkitError.fail("INVALID_PARAMS", "Cannot bake: no outlines defined") + + # NavigationServer2D baking (parse/bake_from_source_geometry_data) is present on + # all supported versions (4.2-4.7); has_method() guards it defensively. + if NavigationServer2D.has_method("bake_from_source_geometry_data"): + var source := NavigationMeshSourceGeometryData2D.new() + NavigationServer2D.parse_source_geometry_data(nav_poly, source, region) + NavigationServer2D.bake_from_source_geometry_data(nav_poly, source) + else: + # Deprecated fallback — unreachable on 4.2-4.7 (bake API present since 4.2) + nav_poly.make_polygons_from_outlines() + + var vertex_count := 0 + for i in nav_poly.get_polygon_count(): + vertex_count += nav_poly.get_polygon(i).size() + + return MCPToolkitSuccess.ok({ + "outline_count": nav_poly.get_outline_count(), + "polygon_count": nav_poly.get_polygon_count(), + "vertex_count": vertex_count, + }) + + +# -- Undo/Redo --------------------------------------------------------------- + + +# Register the already-applied polygon mutation with the editor's UndoRedo (same +# node-hosted-resource shape as path2d.edit_curve): redo re-assigns the mutated +# polygon, undo restores the pre-mutation duplicate; both resources are reference- +# pinned so neither can be collected while the history entry lives. bake is +# deliberately NOT wrapped — baked polygons are derivative of the outlines. +static func _wrap_undo_redo(region: NavigationRegion2D, nav_poly: NavigationPolygon, + old_poly: NavigationPolygon, action_name: String) -> void: + MCPToolkitUndoRedoAction.begin(action_name, region) \ + .do_property(region, &"navigation_polygon", nav_poly) \ + .undo_property(region, &"navigation_polygon", old_poly) \ + .do_reference(nav_poly) \ + .undo_reference(old_poly) \ + .commit_recorded() + + +static func _make_result(nav_poly: NavigationPolygon) -> Dictionary: + var total_vertices := 0 + for i in nav_poly.get_outline_count(): + total_vertices += nav_poly.get_outline(i).size() + return MCPToolkitSuccess.ok({ + "outline_count": nav_poly.get_outline_count(), + "vertex_count": total_vertices, + }) diff --git a/addons/godot_mcp_toolkit/commands/navigation_commands.gd.uid b/addons/godot_mcp_toolkit/commands/navigation_commands.gd.uid new file mode 100644 index 0000000..47d3d1f --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/navigation_commands.gd.uid @@ -0,0 +1 @@ +uid://bxui4db4m5yl3 diff --git a/addons/godot_mcp_toolkit/commands/node_commands.gd b/addons/godot_mcp_toolkit/commands/node_commands.gd new file mode 100644 index 0000000..cdae39e --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/node_commands.gd @@ -0,0 +1,1208 @@ +@tool +extends RefCounted +## node.* command handlers — property get/set/list, method calls, script attachment. + +const Modules := preload("res://addons/godot_mcp_toolkit/core/modules.gd") +const Coerce = Modules.Coerce +const FileGuard = Modules.FileGuard +const Helpers = Modules.CommandHelpers + +## node.set_property rejects "groups" because its set is a declarative full +## replace (it would drop any group not in the list), whereas node.groups is +## incremental. The two semantics are opposites, so groups go through one tool. +const _GROUPS_REJECTION_MESSAGE := "'groups' cannot be set via node.set_property: it would replace the whole group list and silently drop any group not included." +const _GROUPS_REJECTION_HINT := "Use node.groups (action 'add'/'remove') to change group membership incrementally, or 'list' to read it." + + +const COMMON_PROPERTIES_BY_CLASS := { + "Node": ["name", "process_mode"], + "Node2D": ["position", "rotation", "scale", "z_index", "visible", "modulate"], + "Node3D": ["position", "rotation", "scale", "visible"], + "Control": ["position", "size", "anchor_left", "anchor_right", "anchor_top", + "anchor_bottom", "visible", "modulate", "size_flags_horizontal", "size_flags_vertical"], + "Sprite2D": ["texture", "centered", "offset", "flip_h", "flip_v", "hframes", "vframes", "frame"], + "Sprite3D": ["texture", "centered", "offset", "flip_h", "flip_v"], + "CollisionShape2D": ["shape", "disabled"], + "CollisionShape3D": ["shape", "disabled"], + "RigidBody2D": ["mass", "gravity_scale", "linear_velocity", "angular_velocity"], + "RigidBody3D": ["mass", "gravity_scale", "linear_velocity", "angular_velocity"], + "CharacterBody2D": ["velocity", "floor_max_angle", "up_direction"], + "CharacterBody3D": ["velocity", "floor_max_angle", "up_direction"], + "Camera2D": ["zoom", "offset", "position_smoothing_enabled"], + "Camera3D": ["fov", "near", "far", "current"], + "Area2D": ["monitoring", "monitorable", "gravity"], + "Area3D": ["monitoring", "monitorable", "gravity"], + "AnimationPlayer": ["current_animation", "autoplay", "speed_scale"], + "Timer": ["wait_time", "one_shot", "autostart"], + "Label": ["text", "horizontal_alignment", "vertical_alignment", "autowrap_mode"], + "Button": ["text", "disabled", "flat"], + "TextureRect": ["texture", "stretch_mode"], + "AudioStreamPlayer": ["stream", "volume_db", "pitch_scale", "autoplay"], + "AudioStreamPlayer2D": ["stream", "volume_db", "pitch_scale", "max_distance"], + "AudioStreamPlayer3D": ["stream", "volume_db", "pitch_scale", "max_distance"], + "MeshInstance3D": ["mesh", "material_override"], + "Light2D": ["energy", "color", "shadow_enabled"], + "DirectionalLight3D": ["light_energy", "light_color", "shadow_enabled"], + "GPUParticles2D": ["process_material", "emitting", "amount", "lifetime"], + "GPUParticles3D": ["process_material", "emitting", "amount", "lifetime"], + "LineEdit": ["text", "placeholder_text", "editable", "max_length"], + "TextEdit": ["text", "editable"], + "RichTextLabel": ["text", "bbcode_enabled"], +} + + +static func register(registry: MCPToolkitCommandRegistry, server: Node) -> void: + registry.add("node.get_property", func(parameters: Dictionary) -> Dictionary: + return _cmd_node_get_property(parameters) + , MCPToolkitCommandOptions.new().mark_read_only()) + registry.add("node.set_property", func(parameters: Dictionary) -> Dictionary: + return _cmd_node_set_property(server, parameters) + , MCPToolkitCommandOptions.new()) + registry.add("node.get_property_list", func(parameters: Dictionary) -> Dictionary: + return _cmd_node_get_property_list(parameters) + , MCPToolkitCommandOptions.new().mark_read_only()) + registry.add("node.call_method", func(parameters: Dictionary) -> Dictionary: + return _cmd_node_call_method(parameters) + , MCPToolkitCommandOptions.new()) + registry.add("node.set_script", func(parameters: Dictionary) -> Dictionary: + return _cmd_node_set_script(parameters) + , MCPToolkitCommandOptions.new()) + registry.add("node.manage", func(parameters: Dictionary) -> Dictionary: + return _cmd_node_manage(parameters) + , MCPToolkitCommandOptions.new()) + registry.add("node.groups", func(parameters: Dictionary) -> Dictionary: + return _cmd_node_groups(parameters) + , MCPToolkitCommandOptions.new()) + registry.add("node.collision_from_sprite", func(parameters: Dictionary) -> Dictionary: + return _cmd_collision_from_sprite(parameters) + , MCPToolkitCommandOptions.new()) + registry.add("control.set_layout", func(parameters: Dictionary) -> Dictionary: + return _cmd_control_set_layout(parameters) + , MCPToolkitCommandOptions.new()) + + +# -- Helpers ------------------------------------------------------------------ + + +static func _get_edited_root() -> Node: + return Helpers.get_edited_root() + + +static func _resolve_scene_node(node_path: String) -> Variant: + return Helpers.resolve_scene_node(node_path) + + +## True for the one property name node.set_property refuses in favour of +## node.groups. Pure (no engine state) so single- and batch-mode share one +## decision and a unit test can pin it without an editor. +static func _is_groups_property(property_name: String) -> bool: + return property_name == "groups" + + +## Detect silent compound-path set failure by comparing readback to expected. +static func _iscompound_set_failure(expected: Variant, actual: Variant) -> bool: + if expected == null: + return false # Setting null — nothing to verify + if actual == null: + return true # Expected non-null, got null + if typeof(actual) == TYPE_DICTIONARY and (actual as Dictionary).is_empty(): + if expected is Resource: + return true # Expected resource, got empty dict + if typeof(expected) == TYPE_DICTIONARY and not (expected as Dictionary).is_empty(): + return true # Expected populated dict, got empty dict + return false + + +## Short string representation of a value for warning messages. +static func _brief_value(value: Variant) -> String: + if value == null: + return "null" + if typeof(value) == TYPE_DICTIONARY and (value as Dictionary).is_empty(): + return "{} (empty)" + var s := var_to_str(value) + if s.length() > 60: + s = s.left(57) + "..." + return s + + +# -- Commands ----------------------------------------------------------------- + + +static func _cmd_node_get_property(parameters: Dictionary) -> Dictionary: + var root := _get_edited_root() + if root == null: + return MCPToolkitError.fail("NO_SCENE", "no edited scene") + + var node_path := str(parameters.get("node_path", "")) + node_path = Helpers.normalize_editor_path(node_path) + var property_name := str(parameters.get("property", "")) + + if node_path.is_empty() or property_name.is_empty(): + return MCPToolkitError.fail("INVALID_PARAMS", "missing node_path or property") + + var node := root.get_node_or_null(node_path) + if node == null: + return MCPToolkitError.fail("NOT_FOUND", "node not found: %s" % node_path, MCPToolkitError.HINT_NODE_PATH) + + # Compound paths (colon-chained sub-resource paths like + # "material:shader_parameter/value") use the centralized handler which + # tries node-level override first, then falls back to sub-resource read. + if ":" in property_name: + var result := Helpers.get_property_compound(node, property_name) + if not result.get("ok", false): + return MCPToolkitError.fail( + result.get("code", "NOT_FOUND"), + str(result.get("error", ""))) + return MCPToolkitSuccess.ok({"value": result["value"]}) + + return MCPToolkitSuccess.ok({"value": Coerce.serialize_value(node.get(property_name))}) + + +static func _cmd_node_set_property(server: Node, parameters: Dictionary) -> Dictionary: + var root := _get_edited_root() + if root == null: + return MCPToolkitError.fail("NO_SCENE", "no edited scene") + + # Batch mode — set multiple properties in a single UndoRedo action. + var batch_raw = parameters.get("batch", null) + if batch_raw != null and typeof(batch_raw) == TYPE_ARRAY and (batch_raw as Array).size() > 0: + return _batch_set_properties(server, root, batch_raw as Array) + + var node_path := str(parameters.get("node_path", "")) + node_path = Helpers.normalize_editor_path(node_path) + var property_name := str(parameters.get("property", "")) + var raw_value = parameters.get("value", null) + + if node_path.is_empty() or property_name.is_empty(): + return MCPToolkitError.fail("INVALID_PARAMS", "missing node_path or property") + + # "groups" is not a regular property — it lives in the .tscn node header, + # and a full-set-replace here would silently strip any group not in the + # given list. Steer all group mutation to node.groups, whose verbs are + # incremental (and which both tools being eager makes always reachable). + # Checked before node resolution so the error is consistent regardless of + # whether node_path is valid (mirrors the batch-mode placement). + if _is_groups_property(property_name): + return MCPToolkitError.fail("INVALID_PARAMS", + _GROUPS_REJECTION_MESSAGE, _GROUPS_REJECTION_HINT) + + var node := root.get_node_or_null(node_path) + if node == null: + return MCPToolkitError.fail("NOT_FOUND", "node not found: %s" % node_path, MCPToolkitError.HINT_NODE_PATH) + + # Compound / colon-chained paths (e.g. "libraries/test", + # "material:shader_parameter/value", "theme_override_colors/font_color") take + # the sub-resource navigation + UndoRedo path; everything else is a scalar set. + if ":" in property_name or "/" in property_name: + return _set_property_compound_single( + server, node, node_path, property_name, raw_value, parameters) + return _set_property_scalar_single(node, node_path, property_name, raw_value) + + +## Set one compound / colon-chained property and register its UndoRedo. The +## centralized handler in editor_helpers.gd does the sub-resource navigation, +## shader_parameter/ dedicated setter, value coercion, and readback; it returns +## _undo info we register here via commit_recorded(). Leaf mechanics of +## node.set_property's compound branch, lifted out of the handler so the latter +## reads as a top-down orchestrator. +## Reads only `make_unique` from parameters. Returns the response dict directly. +static func _set_property_compound_single( + server: Node, node: Node, node_path: String, property_name: String, + raw_value: Variant, parameters: Dictionary, +) -> Dictionary: + var do_unique := bool(parameters.get("make_unique", false)) + var result := Helpers.set_property_compound( + node, property_name, raw_value, do_unique) + if not result.get("ok", false): + return MCPToolkitError.fail( + result.get("code", "INVALID_VALUE"), + str(result.get("error", ""))) + + # Register UndoRedo for compound paths. All do/undo methods route + # through server.undo_helpers.compound_set for consistent + # EditorUndoRedoManager context (avoids history mismatch errors). + var ui: Dictionary = result.get("_undo", {}) + var undo_type: String = str(ui.get("type", "")) + if undo_type != "": + var undo_path: String = ui["path"] + var new_val = ui.get("new", node.get(undo_path)) + var action = MCPToolkitUndoRedoAction.begin("set %s.%s" % [node_path, property_name], node) + action.do_method(server.undo_helpers.compound_set.bind( + node, undo_path, new_val)) + action.undo_method(server.undo_helpers.compound_set.bind( + node, undo_path, ui["old"])) + # make_unique: undo restores original external resource. + if ui.has("old_resource_prop"): + var res_prop: String = ui["old_resource_prop"] + var new_res = node.get(res_prop) + action.do_method(server.undo_helpers.compound_set.bind( + node, res_prop, new_res)) + action.undo_method(server.undo_helpers.compound_set.bind( + node, res_prop, ui["old_resource"])) + if new_res is Resource: + action.do_reference(new_res) + if ui["old_resource"] is Resource: + action.undo_reference(ui["old_resource"]) + action.commit_recorded() + + var response := {} + if result.has("made_unique"): + response["made_unique"] = result["made_unique"] + if result.has("warning"): + response["warning"] = result["warning"] + return MCPToolkitSuccess.ok(response) + + +## Set one scalar (non-compound) property: coerce → set → register UndoRedo, +## then the bare-res:// readback guard (a bare "res://…" string assigned +## to a Resource-typed property silently fails to load — detect it and steer the +## caller to the tagged {type:"Resource", path:…} form). Leaf mechanics of +## node.set_property's scalar branch, lifted out of the handler for SRP. +## Needs no `server` — scalar undo uses do/undo_property. +static func _set_property_scalar_single( + node: Node, node_path: String, property_name: String, raw_value: Variant, +) -> Dictionary: + var coerce_result := Helpers.coerce_for_property(node, property_name, raw_value) + if not coerce_result.get("ok", false): + return MCPToolkitError.fail( + coerce_result.get("code", "INVALID_VALUE"), + str(coerce_result.get("error", ""))) + var coerced = coerce_result["value"] + + var old_value = node.get(property_name) + + # Drop Godot 4.3's SceneTreeEditor tooltip-timer connections before writing + # editor_description so this set arms no timer against a soon-freed tree row + # (no-op for other properties / other versions). A later human undo/redo replays + # the set outside this guard — out of scope, cured in 4.4. See disarm_tooltip_uaf. + Helpers.disarm_tooltip_uaf(node, property_name) + node.set(property_name, coerced) + # Bare res:// string on a Resource-typed property: give the specific + # tagged-form hint (a bare path silently fails to load) BEFORE the generic + # drop guard below, which would otherwise fire first with a vaguer message. + # Signature: raw String + non-Resource coercion + non-String readback. + if typeof(raw_value) == TYPE_STRING and str(raw_value).begins_with("res://") \ + and not (coerced is Resource) and not (node.get(property_name) is String): + return MCPToolkitError.fail("INVALID_VALUE", + "property '%s' expects a Resource, not a bare string path. " % property_name + + "Use {\"type\": \"Resource\", \"path\": \"%s\"} as the value." % str(raw_value)) + # Classify the write. DROPPED (silent wrong-type) fails before undo — nothing + # landed, so there is no state to roll back. ADJUSTED (engine reshaped the value, + # e.g. truncation 7.9→7 or a normalize) DID commit, so it records undo like a + # clean write and returns success WITH a warning naming the stored-vs-requested + # delta. OK is a clean success. + var outcome := Helpers.describe_set_drop(old_value, node.get(property_name), coerced, property_name) + if outcome.get("status", "") == "dropped": + # A bound setter (position/modulate) may have Variant-converted the wrong type + # to a ZERO and stored it; restore the prior value so a SET_FAILED is truly + # non-destructive. Done before undo registration — nothing to roll back. + node.set(property_name, old_value) + return MCPToolkitError.fail("SET_FAILED", str(outcome.get("error", ""))) + var action = MCPToolkitUndoRedoAction.begin("set %s.%s" % [node_path, property_name], node) + action.do_property(node, property_name, coerced) + action.undo_property(node, property_name, old_value) + if coerced is Resource: + action.do_reference(coerced) + if old_value is Resource: + action.undo_reference(old_value) + action.commit_recorded() + if outcome.get("status", "") == "adjusted": + return MCPToolkitSuccess.ok({"warning": str(outcome.get("warning", ""))}) + return MCPToolkitSuccess.ok() + + +## Batch set multiple properties in one UndoRedo action. +## Compound paths (: or /) use set_property_compound() and add their undo +## info to the same batch action when possible (property-type paths only). +static func _batch_set_properties(server: Node, root: Node, entries: Array) -> Dictionary: + var action = MCPToolkitUndoRedoAction.begin("batch set %d properties" % entries.size(), root) + + var results: Array = [] + for entry in entries: + if typeof(entry) != TYPE_DICTIONARY: + results.append({"success": false, "error": "entry must be an object"}) + continue + var np := str(entry.get("node_path", "")) + np = Helpers.normalize_editor_path(np) + var prop := str(entry.get("property", "")) + var raw_val = entry.get("value", null) + + if np.is_empty() or prop.is_empty(): + results.append({"node_path": np, "property": prop, + "success": false, "error": "missing node_path or property"}) + continue + + # Reject "groups" per-entry (the rest of the batch still applies). Without + # this it would fall through to node.set("groups", …) — a non-property — + # and vanish silently; group mutation belongs to node.groups (incremental). + if _is_groups_property(prop): + results.append({"node_path": np, "property": prop, "success": false, + "error": _GROUPS_REJECTION_MESSAGE, "hint": _GROUPS_REJECTION_HINT}) + continue + + var node := root.get_node_or_null(np) + if node == null: + results.append({"node_path": np, "property": prop, + "success": false, "error": "node not found"}) + continue + + # Compound paths: route through set_property_compound() which handles + # colon→slash conversion, sub-resource navigation, and readback. + # All compound undo uses server.undo_helpers.compound_set to keep + # the entire batch in a consistent EditorUndoRedoManager context. + if ":" in prop or "/" in prop: + var do_unique := bool(entry.get("make_unique", false)) + var result := Helpers.set_property_compound( + node, prop, raw_val, do_unique) + if result.get("ok", false): + var ui: Dictionary = result.get("_undo", {}) + var undo_type: String = str(ui.get("type", "")) + if undo_type != "": + var undo_path: String = ui["path"] + var new_val = ui.get("new", node.get(undo_path)) + action.do_method(server.undo_helpers.compound_set.bind( + node, undo_path, new_val)) + action.undo_method(server.undo_helpers.compound_set.bind( + node, undo_path, ui["old"])) + if ui.has("old_resource_prop"): + var res_prop: String = ui["old_resource_prop"] + var new_res = node.get(res_prop) + action.do_method(server.undo_helpers.compound_set.bind( + node, res_prop, new_res)) + action.undo_method(server.undo_helpers.compound_set.bind( + node, res_prop, ui["old_resource"])) + if new_res is Resource: + action.do_reference(new_res) + if ui["old_resource"] is Resource: + action.undo_reference(ui["old_resource"]) + var res_entry := {"node_path": np, "property": prop, "success": true} + if result.has("made_unique"): + res_entry["made_unique"] = result["made_unique"] + if result.has("warning"): + res_entry["warning"] = result["warning"] + results.append(res_entry) + else: + results.append({"node_path": np, "property": prop, + "success": false, "error": str(result.get("error", ""))}) + continue + + var missing := Coerce.check_resource_paths(raw_val) + if missing != "": + results.append({"node_path": np, "property": prop, + "success": false, "error": "resource not found: %s" % missing}) + continue + + var coerced = Coerce.coerce_value(raw_val) + if typeof(coerced) == TYPE_DICTIONARY and (coerced as Dictionary).has("_coerce_error"): + results.append({"node_path": np, "property": prop, + "success": false, "error": str(coerced["_coerce_error"])}) + continue + + var old_value = node.get(prop) + if typeof(old_value) == TYPE_NODE_PATH and typeof(coerced) == TYPE_STRING: + coerced = NodePath(str(coerced)) + + # Apply mutation unconditionally, then record for undo. Disarm the Godot 4.3 + # tooltip-timer UAF first when prop is editor_description (no-op otherwise). + Helpers.disarm_tooltip_uaf(node, prop) + node.set(prop, coerced) + # Classify: DROPPED → per-entry failure (skip undo; the rest of the batch + # still applies, summarize_batch rolls it up). ADJUSTED/OK committed → register + # undo; ADJUSTED adds a per-entry warning naming the engine's value reshape. + var outcome := Helpers.describe_set_drop(old_value, node.get(prop), coerced, prop) + if outcome.get("status", "") == "dropped": + # Restore the prior value (a bound setter may have zeroed it) so this + # failed entry is non-destructive; skip its undo, rest of batch applies. + node.set(prop, old_value) + results.append({"node_path": np, "property": prop, + "success": false, "error": str(outcome.get("error", ""))}) + continue + action.do_method(server.undo_helpers.compound_set.bind(node, prop, coerced)) + action.undo_method(server.undo_helpers.compound_set.bind(node, prop, old_value)) + if coerced is Resource: + action.do_reference(coerced) + if old_value is Resource: + action.undo_reference(old_value) + var scalar_entry := {"node_path": np, "property": prop, "success": true} + if outcome.get("status", "") == "adjusted": + scalar_entry["warning"] = str(outcome.get("warning", "")) + results.append(scalar_entry) + + action.commit_recorded() + + # Aggregate warnings for non-persisting external sub-resource mutations. + var non_persisting: PackedStringArray = [] + for r in results: + if r.has("warning"): + non_persisting.append(str(r.get("property", ""))) + var response := {"results": results} + if non_persisting.size() > 0: + response["warning"] = ( + "These compound paths were set on shared external sub-resources " + + "and may not persist after save/reload: %s. " % ", ".join(non_persisting) + + "Retry those entries with make_unique: true to auto-duplicate " + + "them as inline copies that persist.") + # Roll per-entry failures up into a top-level failed count + hint (additive: + # all-success batches are byte-identical; the existing warning is preserved). + return MCPToolkitSuccess.ok(Helpers.summarize_batch(response, "results")) + + +static func _resolve_common_property_names(node: Object) -> Array[String]: + var result: Array[String] = [] + var current := node.get_class() + var depth := 0 + while not current.is_empty() and depth < 16: + if COMMON_PROPERTIES_BY_CLASS.has(current): + for prop_name in COMMON_PROPERTIES_BY_CLASS[current]: + if prop_name not in result: + result.append(prop_name) + if ClassDB.class_exists(current): + current = ClassDB.get_parent_class(current) + else: + break + depth += 1 + return result + + +static func _cmd_node_get_property_list(parameters: Dictionary) -> Dictionary: + var root := _get_edited_root() + if root == null: + return MCPToolkitError.fail("NO_SCENE", "no edited scene") + var node_path := str(parameters.get("node_path", "")) + node_path = Helpers.normalize_editor_path(node_path) + var node = _resolve_scene_node(node_path) + if node == null: + return MCPToolkitError.fail("NOT_FOUND", "node not found: %s" % node_path, MCPToolkitError.HINT_NODE_PATH) + var mask := str(parameters.get("mask", "common")) + if not (mask in ["common", "all", "groups", "script"]): + return MCPToolkitError.fail("INVALID_PARAMS", + "mask must be 'common', 'all', 'groups', or 'script' (got '%s')" % mask) + var visibility_filter := str(parameters.get("visibility", "all")) + if mask == "script" and not (visibility_filter in ["public", "private", "all"]): + return MCPToolkitError.fail("INVALID_PARAMS", + "visibility must be 'public', 'private', or 'all' (got '%s')" % visibility_filter) + var common_names: Array[String] = [] + if mask == "common": + common_names = _resolve_common_property_names(node) + var properties: Array = [] + if mask == "script": + # Use Script.get_script_property_list() directly — it works for both + # @tool and non-@tool scripts, unlike node.get_property_list() which + # may omit PROPERTY_USAGE_SCRIPT_VARIABLE for non-@tool scripts. + var script: Script = node.get_script() as Script + if script != null: + for property in script.get_script_property_list(): + var usage: int = int(property.get("usage", 0)) + if not (usage & PROPERTY_USAGE_EDITOR): + continue + var property_name := str(property.get("name", "")) + if property_name.is_empty(): + continue + var vis := "private" if property_name.begins_with("_") else "public" + if visibility_filter != "all" and vis != visibility_filter: + continue + properties.append({ + "name": property_name, + "type": int(property.get("type", 0)), + "hint": int(property.get("hint", 0)), + "hint_string": str(property.get("hint_string", "")), + "visibility": vis, + }) + else: + for property in node.get_property_list(): + var usage: int = int(property.get("usage", 0)) + var property_name := str(property.get("name", "")) + if property_name.is_empty(): + continue + if not (usage & PROPERTY_USAGE_EDITOR): + continue + if property_name.begins_with("_"): + continue + if mask == "common" and property_name not in common_names: + continue + if mask == "groups": + properties.append({ + "name": property_name, + "usage": usage, + }) + else: + properties.append({ + "name": property_name, + "type": int(property.get("type", 0)), + "hint": int(property.get("hint", 0)), + "hint_string": str(property.get("hint_string", "")), + }) + return MCPToolkitSuccess.ok({ + "path": node_path, + "class": node.get_class(), + "mask": mask, + "properties": properties, + "count": properties.size(), + }) + + +## Returns the version-tailored stale-live-instance recovery hint when the target node's +## ON-DISK .gd defines `method_name` (and compiles) but the live instance lacks it — +## i.e. the instance is stale, not the call wrong. Fires on Godot < 4.4 (any mode) and on +## 4.4+ when HEADLESS (a headless editor never re-instantiates a reloaded node). "" +## otherwise (no script / non-.gd / method genuinely absent / disk won't compile / 4.4+ +## with a display). The pure decision lives in StaleInstanceHint; this just reads the +## running version + headless flag + the on-disk source. Called only on the +## INVALID_METHOD error path. +static func _stale_method_hint(node: Object, method_name: String) -> String: + var scr = node.get_script() + if scr == null: + return "" + var scr_path := str(scr.resource_path) + if not scr_path.to_lower().ends_with(".gd") or not FileAccess.file_exists(scr_path): + return "" + var disk_source := FileAccess.get_file_as_string(scr_path) + var version := Modules.VersionUtils.get_engine_version_ints() + var headless := Modules.VersionUtils.is_headless() + var disk_has := Modules.StaleInstanceHint.source_has_method(disk_source, method_name) + var disk_ok := Modules.StaleInstanceHint.source_compiles(disk_source) + if not Modules.StaleInstanceHint.should_hint_on_call(false, disk_has, disk_ok, true, version.x, version.y, headless): + return "" + return Modules.StaleInstanceHint.recovery_message( + Modules.VersionUtils.get_engine_version_pair(), version.y, headless) + + +## Builds the node.call_method hint for a null callv() result, version-gated. +## +## A non-@tool GDScript never runs in the editor, so callv() cannot dispatch its +## method and returns null (the editor logs "Method not found"). The hint leads with +## the reliable runtime path, then the editor @tool fix. That fix is gated at 4.5: +## on 4.5+ an attached instance's method table is rebuilt only by fully reopening the +## scene (scene_close + scene_open) — editor_refresh does not suffice — while below +## 4.5 no MCP-actionable scene reopen exists, so the editor must be relaunched. C# +## additionally needs a project rebuild, on every version. +## [param ver_pair] the running engine "major.minor" (drives the 4.5 gate). +static func _call_method_null_hint(is_csharp: bool, ver_pair: String) -> String: + if is_csharp: + return ("Return value was null. C# methods cannot execute in editor mode without " + + "the [Tool] attribute — Godot registers the method signature but does not " + + "instantiate the managed .NET object. Properties and signals work normally. Use " + + "game.start + execute_code to call C# methods at runtime, or set state via " + + "node.set_property (most C# logic runs in _Ready() at startup). To call it in the " + + "editor, add [Tool], then rebuild the C# project and relaunch the editor.") + var editor_tail := ("add @tool, then close and reopen the scene (scene_close + scene_open); " + + "editor_refresh is not sufficient") + if not Modules.VersionUtils.is_at_least(ver_pair, "4.5"): + editor_tail = "add @tool, then relaunch the editor" + return ("Return value was null. A non-@tool GDScript never runs in the editor, so callv() " + + "cannot find the method (the editor logs 'Method not found'). To call it: (1) runtime, " + + "reliable — game.start, then execute_code or runtime_get_node_state on the live node; " + + "(2) editor — " + editor_tail + ".") + + +static func _cmd_node_call_method(parameters: Dictionary) -> Dictionary: + var root := _get_edited_root() + if root == null: + return MCPToolkitError.fail("NO_SCENE", "no open scene; use scene.open or scene.create first") + + var node_path := str(parameters.get("node_path", "")) + node_path = Helpers.normalize_editor_path(node_path) + var method_name := str(parameters.get("method_name", "")) + var args_raw = parameters.get("args", []) + # Resource refs in args are validated via Coerce.check_resource_paths, + # which gates through FileGuard. node_path is a scene-tree path, not filesystem. + + if node_path.is_empty() or method_name.is_empty(): + return MCPToolkitError.fail("INVALID_PARAMS", "missing node_path or method_name") + if typeof(args_raw) != TYPE_ARRAY: + return MCPToolkitError.fail("INVALID_PARAMS", + "args must be an Array (got %s)" % typeof(args_raw)) + + var node := root.get_node_or_null(node_path) + if node == null: + return MCPToolkitError.fail("NOT_FOUND", + "no node at path %s. This tool is editor-only — for runtime nodes use execute_code or runtime_get_node_state." % node_path) + if not node.has_method(method_name): + var no_method_msg := "node %s has no method '%s'; use scene.get_tree or inspect the script class via ClassDB" % [ + node_path, method_name] + var stale_hint := _stale_method_hint(node, method_name) + if stale_hint != "": + return MCPToolkitError.fail("INVALID_METHOD", no_method_msg, stale_hint) + return MCPToolkitError.fail("INVALID_METHOD", no_method_msg) + + var missing := Coerce.check_resource_paths(args_raw) + if missing != "": + return MCPToolkitError.fail("LOAD_FAILED", + "failed to load resource at %s; verify the path or use resource.write to create it first" % missing) + + var coerced_args = Coerce.coerce_value(args_raw) + if typeof(coerced_args) != TYPE_ARRAY: + coerced_args = [] + for arg in coerced_args: + if typeof(arg) == TYPE_DICTIONARY and (arg as Dictionary).has("_coerce_error"): + return MCPToolkitError.fail("INVALID_PARAMS", str(arg["_coerce_error"])) + print("[MCPTools] node.call_method invoked %s.%s(%d args)" % [ + node_path, method_name, (coerced_args as Array).size()]) + var result = node.callv(method_name, coerced_args) + + var response := { + "path": node_path, + "method": method_name, + "result": Coerce.serialize_value(result), + } + if result == null: + var script = node.get_script() + var is_csharp := script != null and str(script.resource_path).ends_with(".cs") + response["hint"] = _call_method_null_hint(is_csharp, Modules.VersionUtils.get_engine_version_pair()) + return MCPToolkitSuccess.ok(response) + + +static func _cmd_node_set_script(parameters: Dictionary) -> Dictionary: + var root := _get_edited_root() + if root == null: + return MCPToolkitError.fail("NO_SCENE", "no edited scene") + + var node_path := str(parameters.get("node_path", "")) + node_path = Helpers.normalize_editor_path(node_path) + if node_path.is_empty(): + return MCPToolkitError.fail("INVALID_PARAMS", "missing node_path") + + var node := root.get_node_or_null(node_path) + if node == null: + return MCPToolkitError.fail("NOT_FOUND", "node not found: %s" % node_path, MCPToolkitError.HINT_NODE_PATH) + + var script_path := str(parameters.get("script_path", "")) + + if script_path.is_empty(): + var old_script = node.get_script() + node.set_script(null) + var clear_action = MCPToolkitUndoRedoAction.begin("clear script on %s" % node_path, node) + clear_action.do_property(node, &"script", null) + clear_action.undo_property(node, &"script", old_script) + if old_script is Resource: + clear_action.undo_reference(old_script) + clear_action.commit_recorded() + return MCPToolkitSuccess.ok({"path": node_path, "script": null, "properties": []}) + + var guard := FileGuard.resolve_safe(script_path) + if guard["error"] != null: + return MCPToolkitError.fail("PATH_DENIED", str(guard["reason"])) + + var loaded = ResourceLoader.load(script_path) + if loaded == null: + return MCPToolkitError.fail("LOAD_FAILED", + "cannot load script at %s; verify the path or use script.write to create it first" % script_path) + if not (loaded is Script): + return MCPToolkitError.fail("INVALID_PARAMS", + "resource at %s is not a Script (got %s)" % [script_path, loaded.get_class()]) + + var old_script = node.get_script() + node.set_script(loaded) + var set_action = MCPToolkitUndoRedoAction.begin("set script %s on %s" % [script_path, node_path], node) + set_action.do_property(node, &"script", loaded) + set_action.undo_property(node, &"script", old_script) + set_action.do_reference(loaded) + if old_script is Resource: + set_action.undo_reference(old_script) + set_action.commit_recorded() + + var exports: Array = [] + for property in loaded.get_script_property_list(): + var usage: int = int(property.get("usage", 0)) + if not (usage & PROPERTY_USAGE_EDITOR): + continue + var property_name := str(property.get("name", "")) + if property_name.is_empty() or property_name.begins_with("_"): + continue + exports.append({ + "name": property_name, + "type": int(property.get("type", 0)), + "hint": int(property.get("hint", 0)), + "hint_string": str(property.get("hint_string", "")), + }) + + return MCPToolkitSuccess.ok({"path": node_path, "script": script_path, "properties": exports}) + + +static func _cmd_node_manage(parameters: Dictionary) -> Dictionary: + var action := str(parameters.get("action", "")) + if action.is_empty(): + return MCPToolkitError.fail("INVALID_PARAMS", "missing action (rename|reparent|reorder|duplicate)") + + var root := _get_edited_root() + if root == null: + return MCPToolkitError.fail("NO_SCENE", "no edited scene") + + var node_path := str(parameters.get("node_path", "")) + node_path = Helpers.normalize_editor_path(node_path) + if node_path.is_empty(): + return MCPToolkitError.fail("INVALID_PARAMS", "missing node_path") + + var node := root.get_node_or_null(node_path) + if node == null: + return MCPToolkitError.fail("NOT_FOUND", "node not found: %s" % node_path, MCPToolkitError.HINT_NODE_PATH) + + match action: + "rename": + return _manage_rename(root, node, node_path, parameters) + "reparent": + return _manage_reparent(root, node, node_path, parameters) + "reorder": + return _manage_reorder(root, node, node_path, parameters) + "duplicate": + return _manage_duplicate(root, node, node_path, parameters) + _: + return MCPToolkitError.fail("INVALID_PARAMS", + "unknown action '%s'; must be rename|reparent|reorder|duplicate" % action) + + +static func _manage_rename( + root: Node, node: Node, node_path: String, parameters: Dictionary, +) -> Dictionary: + var new_name := str(parameters.get("new_name", "")) + if new_name.is_empty(): + return MCPToolkitError.fail("INVALID_PARAMS", "rename requires new_name") + # No root guard: renaming the scene root is valid (the root name is independent + # of the scene file name) and node.set_property already renames it via "name" — + # the two paths must agree. Reparent/reorder/duplicate keep their root guards: + # those ARE structurally invalid for a root. + + var old_name := String(node.name) + node.name = new_name + MCPToolkitUndoRedoAction.begin("rename %s -> %s" % [old_name, new_name], node) \ + .do_property(node, &"name", new_name) \ + .undo_property(node, &"name", old_name) \ + .commit_recorded() + + var parent := node.get_parent() + var new_path := str(root.get_path_to(node)) + return MCPToolkitSuccess.ok({"action": "rename", "old_name": old_name, + "new_name": String(node.name), "new_path": new_path}) + + +static func _manage_reparent( + root: Node, node: Node, node_path: String, parameters: Dictionary, +) -> Dictionary: + var new_parent_path := str(parameters.get("new_parent_path", "")) + new_parent_path = Helpers.normalize_editor_path(new_parent_path) + if new_parent_path.is_empty(): + return MCPToolkitError.fail("INVALID_PARAMS", "reparent requires new_parent_path") + if node == root: + return MCPToolkitError.fail("INVALID_PATH", "cannot reparent the scene root") + + var new_parent := root.get_node_or_null(new_parent_path) + if new_parent == null: + return MCPToolkitError.fail("NOT_FOUND", + "new parent not found: %s" % new_parent_path, MCPToolkitError.HINT_NODE_PATH) + # Prevent reparenting a node under itself (would create a cycle). + if new_parent == node or node.is_ancestor_of(new_parent): + return MCPToolkitError.fail("INVALID_PARAMS", + "cannot reparent a node under itself or a descendant") + + var keep_global := bool(parameters.get("keep_global_transform", true)) + var old_parent := node.get_parent() + var old_index := node.get_index() + + node.reparent(new_parent, keep_global) + node.set_owner(root) + MCPToolkitUndoRedoAction.begin("reparent %s -> %s" % [node_path, new_parent_path], node) \ + .do_method(node.reparent.bind(new_parent, keep_global)) \ + .do_method(node.set_owner.bind(root)) \ + .undo_method(node.reparent.bind(old_parent, keep_global)) \ + .undo_method(old_parent.move_child.bind(node, old_index)) \ + .undo_method(node.set_owner.bind(root)) \ + .commit_recorded() + + var new_path := str(root.get_path_to(node)) + return MCPToolkitSuccess.ok({"action": "reparent", "new_path": new_path}) + + +static func _manage_reorder( + root: Node, node: Node, node_path: String, parameters: Dictionary, +) -> Dictionary: + if not parameters.has("new_index"): + return MCPToolkitError.fail("INVALID_PARAMS", "reorder requires new_index") + var new_index := int(parameters.get("new_index", 0)) + if node == root: + return MCPToolkitError.fail("INVALID_PATH", "cannot reorder the scene root") + + var parent := node.get_parent() + if parent == null: + return MCPToolkitError.fail("INTERNAL", "node has no parent") + var old_index := node.get_index() + var child_count := parent.get_child_count() + if new_index < 0 or new_index >= child_count: + return MCPToolkitError.fail("INVALID_PARAMS", + "new_index %d out of range [0, %d)" % [new_index, child_count]) + + parent.move_child(node, new_index) + MCPToolkitUndoRedoAction.begin("reorder %s to index %d" % [node_path, new_index], node) \ + .do_method(parent.move_child.bind(node, new_index)) \ + .undo_method(parent.move_child.bind(node, old_index)) \ + .commit_recorded() + + return MCPToolkitSuccess.ok({"action": "reorder", "path": node_path, + "old_index": old_index, "new_index": node.get_index()}) + + +static func _manage_duplicate( + root: Node, node: Node, node_path: String, parameters: Dictionary, +) -> Dictionary: + if node == root: + return MCPToolkitError.fail("INVALID_PATH", "cannot duplicate the scene root") + + var dup := node.duplicate() + if dup == null: + return MCPToolkitError.fail("INTERNAL", "Node.duplicate() returned null for %s" % node_path) + + var new_name := str(parameters.get("new_name", "")) + if not new_name.is_empty(): + dup.name = new_name + + var parent_path := str(parameters.get("parent_path", "")) + parent_path = Helpers.normalize_editor_path(parent_path) + var target_parent: Node + if parent_path.is_empty(): + target_parent = node.get_parent() + else: + target_parent = root.get_node_or_null(parent_path) + if target_parent == null: + dup.queue_free() + return MCPToolkitError.fail("NOT_FOUND", + "parent not found: %s" % parent_path, MCPToolkitError.HINT_NODE_PATH) + + target_parent.add_child(dup) + dup.set_owner(root) + MCPToolkitUndoRedoAction.begin("duplicate %s" % node_path, target_parent) \ + .do_method(target_parent.add_child.bind(dup)) \ + .do_method(dup.set_owner.bind(root)) \ + .do_reference(dup) \ + .undo_method(target_parent.remove_child.bind(dup)) \ + .commit_recorded() + + # Apply optional property overrides (position, scale, etc.). + # Use coerce_value_hint so untagged dicts like {x:200,y:300} + # are inferred as Vector2/Vector3/Color from the property type. + var props_raw = parameters.get("properties", null) + if typeof(props_raw) == TYPE_DICTIONARY: + for key in (props_raw as Dictionary): + var prop_name := str(key) + var existing = dup.get(prop_name) + var coerced = Coerce.coerce_value_hint(props_raw[key], existing) + if typeof(coerced) == TYPE_DICTIONARY and (coerced as Dictionary).has("_coerce_error"): + return MCPToolkitError.fail("INVALID_PARAMS", str(coerced["_coerce_error"])) + dup.set(prop_name, coerced) + + var dup_path := str(root.get_path_to(dup)) + return MCPToolkitSuccess.ok({"action": "duplicate", "path": dup_path, + "class": dup.get_class()}) + + +static func _cmd_node_groups(parameters: Dictionary) -> Dictionary: + var action := str(parameters.get("action", "")) + if action.is_empty(): + return MCPToolkitError.fail("INVALID_PARAMS", "missing action (add|remove|list)") + + var root := _get_edited_root() + if root == null: + return MCPToolkitError.fail("NO_SCENE", "no edited scene") + + # Batch mode: entries array present → process N node+group pairs in one UndoRedo action. + var entries_raw = parameters.get("entries", null) + if typeof(entries_raw) == TYPE_ARRAY and (entries_raw as Array).size() > 0: + if action == "list": + return MCPToolkitError.fail("INVALID_PARAMS", + "batch entries not supported with action 'list'; use single mode per node") + return _batch_node_groups(root, action, entries_raw as Array) + + var node_path := str(parameters.get("node_path", "")) + node_path = Helpers.normalize_editor_path(node_path) + if node_path.is_empty(): + return MCPToolkitError.fail("INVALID_PARAMS", + "missing node_path: required for single-node group operations (add/remove/list); omit it only in batch mode, where each item in entries carries its own node_path.", + MCPToolkitError.HINT_NODE_PATH) + + var node := root.get_node_or_null(node_path) + if node == null: + return MCPToolkitError.fail("NOT_FOUND", "node not found: %s" % node_path, MCPToolkitError.HINT_NODE_PATH) + + match action: + "add": + var group := str(parameters.get("group", "")) + if group.is_empty(): + return MCPToolkitError.fail("INVALID_PARAMS", "add requires group name") + var persistent := bool(parameters.get("persistent", true)) + node.add_to_group(group, persistent) + MCPToolkitUndoRedoAction.begin("add %s to group %s" % [node_path, group], node) \ + .do_method(node.add_to_group.bind(group, persistent)) \ + .undo_method(node.remove_from_group.bind(group)) \ + .commit_recorded() + return MCPToolkitSuccess.ok({"action": "add", "node": node_path, "group": group}) + + "remove": + var group := str(parameters.get("group", "")) + if group.is_empty(): + return MCPToolkitError.fail("INVALID_PARAMS", "remove requires group name") + if not node.is_in_group(group): + return MCPToolkitError.fail("NOT_FOUND", + "node %s is not in group '%s'" % [node_path, group]) + node.remove_from_group(group) + MCPToolkitUndoRedoAction.begin("remove %s from group %s" % [node_path, group], node) \ + .do_method(node.remove_from_group.bind(group)) \ + .undo_method(node.add_to_group.bind(group, true)) \ + .commit_recorded() + return MCPToolkitSuccess.ok({"action": "remove", "node": node_path, "group": group}) + + "list": + var groups: Array[String] = [] + for g in node.get_groups(): + var gs := str(g) + if not gs.begins_with("_"): + groups.append(gs) + return MCPToolkitSuccess.ok({"action": "list", "node": node_path, + "groups": groups, "count": groups.size()}) + + _: + return MCPToolkitError.fail("INVALID_PARAMS", + "unknown action '%s'; must be add|remove|list" % action) + + +static func _batch_node_groups(root: Node, batch_action: String, entries: Array) -> Dictionary: + var undo_action = MCPToolkitUndoRedoAction.begin( + "batch %s groups (%d entries)" % [batch_action, entries.size()], root) + + var results: Array = [] + for entry in entries: + var e: Dictionary = entry if typeof(entry) == TYPE_DICTIONARY else {} + var np := str(e.get("node_path", "")) + np = Helpers.normalize_editor_path(np) + var group := str(e.get("group", "")) + if np.is_empty() or group.is_empty(): + results.append({"node_path": np, "group": group, "error": "missing node_path or group"}) + continue + var node := root.get_node_or_null(np) + if node == null: + results.append({"node_path": np, "group": group, "error": "node not found"}) + continue + match batch_action: + "add": + node.add_to_group(group, true) + undo_action.do_method(node.add_to_group.bind(group, true)) + undo_action.undo_method(node.remove_from_group.bind(group)) + results.append({"node_path": np, "group": group, "status": "added"}) + "remove": + if not node.is_in_group(group): + results.append({"node_path": np, "group": group, "error": "not in group"}) + continue + node.remove_from_group(group) + undo_action.do_method(node.remove_from_group.bind(group)) + undo_action.undo_method(node.add_to_group.bind(group, true)) + results.append({"node_path": np, "group": group, "status": "removed"}) + + undo_action.commit_recorded() + + # Entries here carry {status?, error?} with no `success` key — summarize_batch's + # tolerant predicate (no-success + error => failure) counts them. Additive: + # all-success batches keep the same {action, results, count} shape. + var response := {"action": batch_action, "results": results, "count": results.size()} + return MCPToolkitSuccess.ok(Helpers.summarize_batch(response, "results")) + + +const _LAYOUT_PRESETS := { + "PRESET_TOP_LEFT": Control.PRESET_TOP_LEFT, + "PRESET_TOP_RIGHT": Control.PRESET_TOP_RIGHT, + "PRESET_BOTTOM_LEFT": Control.PRESET_BOTTOM_LEFT, + "PRESET_BOTTOM_RIGHT": Control.PRESET_BOTTOM_RIGHT, + "PRESET_CENTER_LEFT": Control.PRESET_CENTER_LEFT, + "PRESET_CENTER_TOP": Control.PRESET_CENTER_TOP, + "PRESET_CENTER_RIGHT": Control.PRESET_CENTER_RIGHT, + "PRESET_CENTER_BOTTOM": Control.PRESET_CENTER_BOTTOM, + "PRESET_CENTER": Control.PRESET_CENTER, + "PRESET_LEFT_WIDE": Control.PRESET_LEFT_WIDE, + "PRESET_TOP_WIDE": Control.PRESET_TOP_WIDE, + "PRESET_RIGHT_WIDE": Control.PRESET_RIGHT_WIDE, + "PRESET_BOTTOM_WIDE": Control.PRESET_BOTTOM_WIDE, + "PRESET_VCENTER_WIDE": Control.PRESET_VCENTER_WIDE, + "PRESET_HCENTER_WIDE": Control.PRESET_HCENTER_WIDE, + "PRESET_FULL_RECT": Control.PRESET_FULL_RECT, +} + + +static func _cmd_control_set_layout(parameters: Dictionary) -> Dictionary: + var root := _get_edited_root() + if root == null: + return MCPToolkitError.fail("NO_SCENE", "no edited scene") + + var node_path := str(parameters.get("node_path", "")) + node_path = Helpers.normalize_editor_path(node_path) + var preset_name := str(parameters.get("preset", "")) + var resize_mode_str := str(parameters.get("resize_mode", "keep_size")) + var margins_raw = parameters.get("margins", null) + + if node_path.is_empty(): + return MCPToolkitError.fail("INVALID_PARAMS", "missing node_path") + if preset_name.is_empty(): + return MCPToolkitError.fail("INVALID_PARAMS", "missing preset") + + var node := root.get_node_or_null(node_path) + if node == null: + return MCPToolkitError.fail("NOT_FOUND", "node not found: %s" % node_path, MCPToolkitError.HINT_NODE_PATH) + if not (node is Control): + return MCPToolkitError.fail("INVALID_CLASS", + "node at %s is %s — control.set_layout requires a Control node" % [ + node_path, node.get_class()]) + + var ctrl: Control = node as Control + + if not _LAYOUT_PRESETS.has(preset_name): + var available := ", ".join(PackedStringArray(_LAYOUT_PRESETS.keys())) + return MCPToolkitError.fail("INVALID_PARAMS", + "unknown preset '%s'. Available: %s" % [preset_name, available]) + + var preset_enum: int = _LAYOUT_PRESETS[preset_name] + var mode: int = Control.PRESET_MODE_KEEP_SIZE \ + if resize_mode_str == "keep_size" \ + else Control.PRESET_MODE_MINSIZE + + # Capture state for UndoRedo + var old_anchor_left := ctrl.anchor_left + var old_anchor_top := ctrl.anchor_top + var old_anchor_right := ctrl.anchor_right + var old_anchor_bottom := ctrl.anchor_bottom + var old_offset_left := ctrl.offset_left + var old_offset_top := ctrl.offset_top + var old_offset_right := ctrl.offset_right + var old_offset_bottom := ctrl.offset_bottom + + # Apply preset + margins directly so margin offsets are relative to the + # NEW anchor positions, not the old ones (UndoRedo queues do-methods, so + # margin values would be computed against stale offsets if queued). + ctrl.set_anchors_and_offsets_preset(preset_enum, mode) + if margins_raw != null and typeof(margins_raw) == TYPE_DICTIONARY: + if margins_raw.has("left"): + ctrl.offset_left += float(margins_raw["left"]) + if margins_raw.has("right"): + ctrl.offset_right += float(margins_raw["right"]) + if margins_raw.has("top"): + ctrl.offset_top += float(margins_raw["top"]) + if margins_raw.has("bottom"): + ctrl.offset_bottom += float(margins_raw["bottom"]) + + # Record for undo using the final property values (already applied). + MCPToolkitUndoRedoAction.begin("control.set_layout %s %s" % [node_path, preset_name], ctrl) \ + .do_property(ctrl, &"anchor_left", ctrl.anchor_left) \ + .do_property(ctrl, &"anchor_top", ctrl.anchor_top) \ + .do_property(ctrl, &"anchor_right", ctrl.anchor_right) \ + .do_property(ctrl, &"anchor_bottom", ctrl.anchor_bottom) \ + .do_property(ctrl, &"offset_left", ctrl.offset_left) \ + .do_property(ctrl, &"offset_top", ctrl.offset_top) \ + .do_property(ctrl, &"offset_right", ctrl.offset_right) \ + .do_property(ctrl, &"offset_bottom", ctrl.offset_bottom) \ + .undo_property(ctrl, &"anchor_left", old_anchor_left) \ + .undo_property(ctrl, &"anchor_top", old_anchor_top) \ + .undo_property(ctrl, &"anchor_right", old_anchor_right) \ + .undo_property(ctrl, &"anchor_bottom", old_anchor_bottom) \ + .undo_property(ctrl, &"offset_left", old_offset_left) \ + .undo_property(ctrl, &"offset_top", old_offset_top) \ + .undo_property(ctrl, &"offset_right", old_offset_right) \ + .undo_property(ctrl, &"offset_bottom", old_offset_bottom) \ + .commit_recorded() + + var response := { + "path": node_path, + "preset": preset_name, + "final_rect": { + "position": {"x": ctrl.position.x, "y": ctrl.position.y}, + "size": {"x": ctrl.size.x, "y": ctrl.size.y}, + }, + } + + # Warn if the Control is inside a Container + var parent := ctrl.get_parent() + if parent != null and parent is Container: + response["warning"] = ( + "This Control is inside a %s container. " % parent.get_class() + + "The container will override layout on the next layout pass. " + + "Consider using size_flags or moving the node outside the container.") + + return MCPToolkitSuccess.ok(response) + + +static func _cmd_collision_from_sprite(parameters: Dictionary) -> Dictionary: + var err = MCPToolkitError.require(parameters, ["sprite_path"]) + if err != null: + return err + + var root := Helpers.get_edited_root() + if root == null: + return MCPToolkitError.fail("NO_SCENE", "no edited scene") + + var sprite_path := str(parameters.get("sprite_path", "")) + sprite_path = Helpers.normalize_editor_path(sprite_path) + var node = Helpers.resolve_scene_node(sprite_path) + if node == null: + return MCPToolkitError.fail("NOT_FOUND", "node not found: %s" % sprite_path, MCPToolkitError.HINT_NODE_PATH) + + if not (node is Sprite2D or node is TextureRect): + return MCPToolkitError.fail("INVALID_CLASS", + "node at %s is %s — expected Sprite2D or TextureRect" % [sprite_path, node.get_class()]) + + var tex = node.get("texture") as Texture2D + if tex == null: + return MCPToolkitError.fail("INVALID_PARAMS", "sprite has no texture") + + var img := tex.get_image() + if img == null: + return MCPToolkitError.fail("INVALID_PARAMS", "cannot read image data") + + var simplification := float(parameters.get("simplification", 2.0)) + + var bitmap := BitMap.new() + bitmap.create_from_image_alpha(img, 0.1) + var polygons := bitmap.opaque_to_polygons( + Rect2(Vector2.ZERO, Vector2(img.get_width(), img.get_height())), + simplification) + + if polygons.is_empty(): + return MCPToolkitError.fail("INVALID_PARAMS", "no opaque regions found in texture") + + # Resolve target parent + var target_parent: Node + var target_parent_path := str(parameters.get("parent_path", "")) + if target_parent_path.is_empty(): + target_parent = node.get_parent() + else: + target_parent_path = Helpers.normalize_editor_path(target_parent_path) + target_parent = root.get_node_or_null(target_parent_path) + if target_parent == null: + return MCPToolkitError.fail("NOT_FOUND", + "target parent not found: %s" % target_parent_path, MCPToolkitError.HINT_NODE_PATH) + + var sprite_name := String(node.name) + var base_name := str(parameters.get("target_name", "%s_collision" % sprite_name)) + var total_points := 0 + var first_path := "" + + var coll_action = MCPToolkitUndoRedoAction.begin("collision from sprite", target_parent) + + for i in range(polygons.size()): + var coll := CollisionPolygon2D.new() + if polygons.size() == 1: + coll.name = base_name + else: + coll.name = "%s_%d" % [base_name, i] + coll.polygon = polygons[i] + total_points += (polygons[i] as PackedVector2Array).size() + + target_parent.add_child(coll) + coll.set_owner(root) + coll_action.do_method(target_parent.add_child.bind(coll)) + coll_action.do_method(coll.set_owner.bind(root)) + coll_action.do_reference(coll) + coll_action.undo_method(target_parent.remove_child.bind(coll)) + + if i == 0: + first_path = str(root.get_path_to(coll)) + + coll_action.commit_recorded() + + return MCPToolkitSuccess.ok({ + "path": first_path, + "polygon_count": polygons.size(), + "total_points": total_points, + }) diff --git a/addons/godot_mcp_toolkit/commands/node_commands.gd.uid b/addons/godot_mcp_toolkit/commands/node_commands.gd.uid new file mode 100644 index 0000000..5a290d4 --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/node_commands.gd.uid @@ -0,0 +1 @@ +uid://dno7d1xxibou6 diff --git a/addons/godot_mcp_toolkit/commands/particle_commands.gd b/addons/godot_mcp_toolkit/commands/particle_commands.gd new file mode 100644 index 0000000..a29500b --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/particle_commands.gd @@ -0,0 +1,738 @@ +@tool +extends RefCounted +## particles.* command handlers — GPU particle system creation with presets. + +const Modules := preload("res://addons/godot_mcp_toolkit/core/modules.gd") +const Coerce = Modules.Coerce +const Helpers = Modules.CommandHelpers + +const _VALID_PRESETS := ["fire", "smoke", "sparks", "rain", "snow", "explosion", "magic", "dust"] + + +static func _class_has_prop(cls: String, prop: String) -> bool: + for p in ClassDB.class_get_property_list(cls): + if p.get("name", "") == prop: + return true + return false + +const _EMISSION_SHAPES := { + "point": ParticleProcessMaterial.EMISSION_SHAPE_POINT, + "sphere": ParticleProcessMaterial.EMISSION_SHAPE_SPHERE, + "sphere_surface": ParticleProcessMaterial.EMISSION_SHAPE_SPHERE_SURFACE, + "box": ParticleProcessMaterial.EMISSION_SHAPE_BOX, + "ring": ParticleProcessMaterial.EMISSION_SHAPE_RING, +} + +# User param name → material property prefix for {prefix}_min / {prefix}_max pairs. +const _RANGE_PARAMS := { + "initial_velocity": "initial_velocity", + "damping": "damping", + "orbit_velocity": "orbit_velocity", + "scale_range": "scale", + "angle": "angle", + "angular_velocity": "angular_velocity", + "hue_variation": "hue_variation", +} + +# Coercion modes shared by the two data-driven passes (_apply_props pass 7 and +# _merge_overrides pass 6). Each value mirrors the EXACT cast the old hand-written +# ladder used so both the emitted property value AND the merged eff value stay +# byte-identical: +# INT/FLOAT/BOOL — wrap the source in int()/float()/bool() before storing. +# VEC3/COLOR — coerce a raw user param via _vec3()/_color() (the merge ladder +# converted the {x,y,z}/{r,g,b,a} request dict into a Vector3/Color). +# RAW — assign the source directly (the apply ladder stored the value +# untouched; by then the preset/merge had already produced the +# right Vector3/Color, so it was assigned as-is). +# _apply_props uses INT/FLOAT/BOOL/RAW (its inputs are already typed); _merge_overrides +# uses INT/FLOAT/BOOL/VEC3/COLOR (its inputs are raw user params). The VEC3/COLOR rows +# are exactly the keys the apply pass treats as RAW — the coercion happens at merge. +enum { _COERCE_INT, _COERCE_FLOAT, _COERCE_BOOL, _COERCE_RAW, _COERCE_VEC3, _COERCE_COLOR } + +# Targets for the applier: the node vs the ParticleProcessMaterial. +enum { _TARGET_NODE, _TARGET_MATERIAL } + +# Data-driven map of the uniform "if eff.has(k): target.set(prop, cast(eff[k])); +# properties_set += 1" apply rows. +# Each row is [eff_key, target, coerce] and contributes weight 1. The eff_key == +# the target property name in every case, so a single `key` field doubles as both. +# Rows stay in apply ORDER (node group, then material group); the genuine +# specials — range pairs (weight 2), the emission-shape name→enum lookup, +# sub-resources, version-gated props, the 2D/3D forks — are NOT in this table and +# remain explicit. Property writes here are mutually independent, so the integer +# count and the final object state are invariant under their relative order. +const _PROP_SPEC := [ + # Node group. + ["amount", _TARGET_NODE, _COERCE_INT], + ["lifetime", _TARGET_NODE, _COERCE_FLOAT], + ["explosiveness", _TARGET_NODE, _COERCE_FLOAT], + ["speed_scale", _TARGET_NODE, _COERCE_FLOAT], + ["one_shot", _TARGET_NODE, _COERCE_BOOL], + ["local_coords", _TARGET_NODE, _COERCE_BOOL], + # Material group (the range pairs + emission-shape arm stay explicit below). + ["direction", _TARGET_MATERIAL, _COERCE_RAW], + ["spread", _TARGET_MATERIAL, _COERCE_FLOAT], + ["gravity", _TARGET_MATERIAL, _COERCE_RAW], + ["color", _TARGET_MATERIAL, _COERCE_RAW], + ["particle_flag_align_y", _TARGET_MATERIAL, _COERCE_BOOL], + ["emission_sphere_radius", _TARGET_MATERIAL, _COERCE_FLOAT], + ["emission_box_extents", _TARGET_MATERIAL, _COERCE_RAW], + ["turbulence_enabled", _TARGET_MATERIAL, _COERCE_BOOL], + ["turbulence_noise_strength", _TARGET_MATERIAL, _COERCE_FLOAT], +] + +# Data-driven map of the uniform simple-override rows of the merge that layers +# user params onto the preset-seeded eff dict. +# Each row is [param_key, coerce] and writes eff[param_key] = coerce(parameters[key]). +# Row order is load-bearing: every shadow append into overrides_applied happens in +# this order and the array's element order is part of the particles.create response +# contract — so keep the rows in exactly this sequence (unlike _PROP_SPEC, whose +# writes are independent and order-invariant). The coercion column uses the merge +# modes (VEC3/COLOR convert the request dict), NOT the apply modes. The genuine +# specials — range pairs and the emission-shape override — are NOT in this table; +# they keep their dedicated blocks in _merge_overrides (different shape: a _min/_max +# pair, and a name-validated string) and append AFTER this table. +const _OVERRIDE_SPEC := [ + ["amount", _COERCE_INT], + ["lifetime", _COERCE_FLOAT], + ["one_shot", _COERCE_BOOL], + ["explosiveness", _COERCE_FLOAT], + ["speed_scale", _COERCE_FLOAT], + ["local_coords", _COERCE_BOOL], + ["direction", _COERCE_VEC3], + ["spread", _COERCE_FLOAT], + ["gravity", _COERCE_VEC3], + ["color", _COERCE_COLOR], + ["particle_flag_align_y", _COERCE_BOOL], + ["emission_sphere_radius", _COERCE_FLOAT], + ["emission_box_extents", _COERCE_VEC3], + ["turbulence_enabled", _COERCE_BOOL], + ["turbulence_noise_strength", _COERCE_FLOAT], +] + +# Presets defined in 2D units. When type=="3d", _adjust_for_3d() is applied. +const _PRESETS := { + "fire": { + "direction": Vector3(0, -1, 0), + "spread": 15.0, + "initial_velocity_min": 30.0, + "initial_velocity_max": 60.0, + "gravity": Vector3(0, 0, 0), + "lifetime": 1.2, + "amount": 24, + "scale_min": 0.8, + "scale_max": 1.5, + "color_ramp": [ + {"offset": 0.0, "color": Color(1, 1, 0, 1)}, + {"offset": 0.35, "color": Color(1, 0.5, 0, 1)}, + {"offset": 0.7, "color": Color(0.6, 0.1, 0, 0.6)}, + {"offset": 1.0, "color": Color(0.2, 0, 0, 0)}, + ], + "alpha_curve": [ + {"x": 0.0, "y": 0.0}, + {"x": 0.1, "y": 1.0}, + {"x": 0.8, "y": 0.6}, + {"x": 1.0, "y": 0.0}, + ], + "scale_curve": [ + {"x": 0.0, "y": 0.5}, + {"x": 0.3, "y": 1.0}, + {"x": 1.0, "y": 0.0}, + ], + }, + "smoke": { + "direction": Vector3(0, -1, 0), + "spread": 25.0, + "initial_velocity_min": 10.0, + "initial_velocity_max": 25.0, + "gravity": Vector3(0, 0, 0), + "damping_min": 1.0, + "damping_max": 2.0, + "lifetime": 3.0, + "amount": 16, + "scale_min": 1.5, + "scale_max": 3.0, + "color_ramp": [ + {"offset": 0.0, "color": Color(0.6, 0.6, 0.6, 0.6)}, + {"offset": 0.5, "color": Color(0.4, 0.4, 0.4, 0.3)}, + {"offset": 1.0, "color": Color(0.2, 0.2, 0.2, 0.0)}, + ], + "alpha_curve": [ + {"x": 0.0, "y": 0.0}, + {"x": 0.15, "y": 0.8}, + {"x": 0.7, "y": 0.4}, + {"x": 1.0, "y": 0.0}, + ], + "scale_curve": [ + {"x": 0.0, "y": 0.3}, + {"x": 0.5, "y": 0.7}, + {"x": 1.0, "y": 1.0}, + ], + }, + "sparks": { + "direction": Vector3(0, -1, 0), + "spread": 180.0, + "initial_velocity_min": 200.0, + "initial_velocity_max": 400.0, + "gravity": Vector3(0, 98, 0), + "damping_min": 1.0, + "damping_max": 3.0, + "lifetime": 0.5, + "amount": 48, + "one_shot": true, + "explosiveness": 1.0, + "scale_min": 0.1, + "scale_max": 0.3, + "particle_flag_align_y": true, + "color_ramp": [ + {"offset": 0.0, "color": Color(1, 1, 0.8, 1)}, + {"offset": 0.3, "color": Color(1, 0.7, 0.2, 1)}, + {"offset": 0.7, "color": Color(1, 0.3, 0, 0.8)}, + {"offset": 1.0, "color": Color(0.5, 0.1, 0, 0)}, + ], + "alpha_curve": [ + {"x": 0.0, "y": 1.0}, + {"x": 0.7, "y": 0.8}, + {"x": 1.0, "y": 0.0}, + ], + "scale_curve": [ + {"x": 0.0, "y": 1.0}, + {"x": 1.0, "y": 0.3}, + ], + }, + "rain": { + "direction": Vector3(0, 1, 0), + "spread": 5.0, + "initial_velocity_min": 300.0, + "initial_velocity_max": 400.0, + "gravity": Vector3(0, 98, 0), + "lifetime": 0.8, + "amount": 64, + "emission_shape": "box", + "emission_box_extents": Vector3(300, 0, 0), + "particle_flag_align_y": true, + "color": Color(0.6, 0.7, 1.0, 0.7), + "scale_min": 0.1, + "scale_max": 0.2, + }, + "snow": { + "direction": Vector3(0, 1, 0), + "spread": 20.0, + "initial_velocity_min": 20.0, + "initial_velocity_max": 40.0, + "gravity": Vector3(0, 20, 0), + "angular_velocity_min": -45.0, + "angular_velocity_max": 45.0, + "lifetime": 4.0, + "amount": 48, + "emission_shape": "box", + "emission_box_extents": Vector3(300, 0, 0), + "color": Color(1, 1, 1, 0.9), + "scale_min": 0.3, + "scale_max": 0.8, + }, + "explosion": { + "direction": Vector3(0, -1, 0), + "spread": 180.0, + "initial_velocity_min": 100.0, + "initial_velocity_max": 200.0, + "gravity": Vector3(0, 49, 0), + "damping_min": 2.0, + "damping_max": 4.0, + "lifetime": 0.6, + "amount": 32, + "one_shot": true, + "explosiveness": 1.0, + "scale_min": 0.5, + "scale_max": 1.5, + "color_ramp": [ + {"offset": 0.0, "color": Color(1, 1, 0.8, 1)}, + {"offset": 0.2, "color": Color(1, 0.6, 0, 1)}, + {"offset": 0.5, "color": Color(0.8, 0.2, 0, 0.7)}, + {"offset": 0.8, "color": Color(0.4, 0.1, 0, 0.3)}, + {"offset": 1.0, "color": Color(0.2, 0.05, 0, 0)}, + ], + "alpha_curve": [ + {"x": 0.0, "y": 1.0}, + {"x": 0.3, "y": 0.9}, + {"x": 0.7, "y": 0.4}, + {"x": 1.0, "y": 0.0}, + ], + "scale_curve": [ + {"x": 0.0, "y": 0.2}, + {"x": 0.15, "y": 1.0}, + {"x": 0.5, "y": 0.7}, + {"x": 1.0, "y": 0.3}, + ], + }, + "magic": { + "direction": Vector3(0, -1, 0), + "spread": 180.0, + "initial_velocity_min": 20.0, + "initial_velocity_max": 50.0, + "gravity": Vector3(0, 0, 0), + "orbit_velocity_min": 0.5, + "orbit_velocity_max": 1.5, + "damping_min": 1.0, + "damping_max": 2.0, + "lifetime": 2.0, + "amount": 24, + "scale_min": 0.3, + "scale_max": 0.8, + "hue_variation_min": -0.15, + "hue_variation_max": 0.15, + "color_ramp": [ + {"offset": 0.0, "color": Color(0.5, 0.2, 1, 1)}, + {"offset": 0.4, "color": Color(0.2, 0.6, 1, 0.8)}, + {"offset": 0.7, "color": Color(0.8, 0.3, 1, 0.5)}, + {"offset": 1.0, "color": Color(0.4, 0.1, 0.8, 0)}, + ], + "alpha_curve": [ + {"x": 0.0, "y": 0.0}, + {"x": 0.1, "y": 1.0}, + {"x": 0.6, "y": 0.7}, + {"x": 1.0, "y": 0.0}, + ], + "scale_curve": [ + {"x": 0.0, "y": 0.5}, + {"x": 0.3, "y": 1.0}, + {"x": 0.7, "y": 0.8}, + {"x": 1.0, "y": 0.2}, + ], + }, + "dust": { + "direction": Vector3(0, -1, 0), + "spread": 180.0, + "initial_velocity_min": 3.0, + "initial_velocity_max": 8.0, + "gravity": Vector3(0, 0, 0), + "damping_min": 0.5, + "damping_max": 1.0, + "lifetime": 5.0, + "amount": 12, + "emission_shape": "box", + "emission_box_extents": Vector3(100, 100, 0), + "color": Color(0.8, 0.75, 0.65, 0.4), + "scale_min": 0.2, + "scale_max": 0.5, + "color_ramp": [ + {"offset": 0.0, "color": Color(0.8, 0.75, 0.65, 0.0)}, + {"offset": 0.2, "color": Color(0.8, 0.75, 0.65, 0.4)}, + {"offset": 0.8, "color": Color(0.7, 0.65, 0.55, 0.3)}, + {"offset": 1.0, "color": Color(0.6, 0.55, 0.45, 0.0)}, + ], + "alpha_curve": [ + {"x": 0.0, "y": 0.0}, + {"x": 0.2, "y": 0.6}, + {"x": 0.6, "y": 0.5}, + {"x": 1.0, "y": 0.0}, + ], + "scale_curve": [ + {"x": 0.0, "y": 0.5}, + {"x": 0.5, "y": 1.0}, + {"x": 1.0, "y": 0.8}, + ], + }, +} + + +static func register(registry: MCPToolkitCommandRegistry, _server: Node) -> void: + registry.add("particles.create", func(p: Dictionary) -> Dictionary: + return _cmd_particles_create(p) + , MCPToolkitCommandOptions.new()) + + +# -- Helpers ------------------------------------------------------------------ + + +static func _vec3(d: Variant) -> Vector3: + if typeof(d) != TYPE_DICTIONARY: + return Vector3.ZERO + return Vector3(float(d.get("x", 0.0)), float(d.get("y", 0.0)), float(d.get("z", 0.0))) + + +static func _color(d: Variant) -> Color: + return Coerce.color_from_dict(d) + + +static func _expand_range(v: Variant) -> Array: + ## Returns [min_val, max_val] as floats. + if typeof(v) == TYPE_DICTIONARY: + return [float(v.get("min", 0.0)), float(v.get("max", 0.0))] + var f := float(v) + return [f, f] + + +static func _adjust_for_3d(data: Dictionary) -> void: + ## Mutate preset data in-place: 2D → 3D coordinate scaling. + if data.has("direction"): + var d: Vector3 = data["direction"] + data["direction"] = Vector3(d.x, -d.y, d.z) + if data.has("gravity"): + data["gravity"] = Vector3(data["gravity"]) / 10.0 + for key in ["initial_velocity_min", "initial_velocity_max"]: + if data.has(key): + data[key] = float(data[key]) / 20.0 + if data.has("emission_box_extents"): + data["emission_box_extents"] = Vector3(data["emission_box_extents"]) / 20.0 + + +static func _build_gradient_texture(points: Array, interp_mode: int) -> Array: + ## Create GradientTexture1D from [{offset, color}]. + ## Returns [GradientTexture1D, Gradient]. + var gradient := Gradient.new() + var offsets := PackedFloat32Array() + var colors := PackedColorArray() + for point in points: + offsets.append(float(point["offset"])) + if point["color"] is Color: + colors.append(point["color"]) + else: + colors.append(_color(point["color"])) + gradient.offsets = offsets + gradient.colors = colors + gradient.interpolation_mode = interp_mode + var texture := GradientTexture1D.new() + texture.gradient = gradient + return [texture, gradient] + + +static func _build_curve_texture(points: Array) -> Array: + ## Create CurveTexture from [{x, y}]. + ## Returns [CurveTexture, Curve]. + var curve := Curve.new() + for point in points: + curve.add_point(Vector2(float(point["x"]), float(point["y"]))) + var texture := CurveTexture.new() + texture.curve = curve + return [texture, curve] + + +static func _apply_props(node: Node, material: ParticleProcessMaterial, eff: Dictionary) -> int: + ## Apply every uniform _PROP_SPEC entry present in eff onto its target, + ## returning the properties_set delta (the data-driven replacement for the + ## simple-property half of pass 7). The range-pair loop and the emission-shape + ## name→enum lookup are NOT covered here and stay explicit in the handler. + var count := 0 + for spec in _PROP_SPEC: + var key: String = spec[0] + if not eff.has(key): + continue + var target: Object = node if spec[1] == _TARGET_NODE else material + var raw: Variant = eff[key] + match spec[2]: + _COERCE_INT: + target.set(key, int(raw)) + _COERCE_FLOAT: + target.set(key, float(raw)) + _COERCE_BOOL: + target.set(key, bool(raw)) + _: # _COERCE_RAW — assign untouched (already the right Vector3/Color). + target.set(key, raw) + count += 1 + return count + + +static func _merge_overrides(eff: Dictionary, parameters: Dictionary) -> Array: + ## Layer the request's user params onto the preset-seeded eff dict (pass 6), + ## returning the overrides_applied array — the names of params that SHADOWED a + ## preset value, in contract order. eff is mutated in place. Three sub-passes, + ## appended strictly in the original order so the returned array is byte-identical: + ## 1. the uniform simple props (_OVERRIDE_SPEC, in order); + ## 2. the range params (_RANGE_PARAMS → {prefix}_min/_max); + ## 3. the emission-shape name override. + ## A param shadows the preset only when eff already holds the key (range: the + ## _min half) — matching the old `if eff.has(...): overrides.append(...)` guards. + var overrides: Array = [] + + # 1. Simple user overrides (table-driven; was the :464-483 match-ladder). + for spec in _OVERRIDE_SPEC: + var key: String = spec[0] + if not parameters.has(key): + continue + if eff.has(key): + overrides.append(key) + var raw: Variant = parameters[key] + match spec[1]: + _COERCE_INT: + eff[key] = int(raw) + _COERCE_BOOL: + eff[key] = bool(raw) + _COERCE_VEC3: + eff[key] = _vec3(raw) + _COERCE_COLOR: + eff[key] = _color(raw) + _: # _COERCE_FLOAT — the old ladder's default arm. + eff[key] = float(raw) + + # 2. Range params: user float or {min,max} → internal _min/_max (was :485-494). + for user_key in _RANGE_PARAMS: + if not parameters.has(user_key): + continue + var prefix: String = _RANGE_PARAMS[user_key] + var r := _expand_range(parameters[user_key]) + if eff.has(prefix + "_min"): + overrides.append(user_key) + eff[prefix + "_min"] = r[0] + eff[prefix + "_max"] = r[1] + + # 3. Emission shape override (was :496-500). The name was already validated + # against _EMISSION_SHAPES in the handler before this merge runs. + var emission_shape_str = parameters.get("emission_shape") + if emission_shape_str != null: + if eff.has("emission_shape"): + overrides.append("emission_shape") + eff["emission_shape"] = str(emission_shape_str) + + return overrides + + +# -- Command ------------------------------------------------------------------ + + +static func _cmd_particles_create(parameters: Dictionary) -> Dictionary: + # --- Validate required --- + var err = MCPToolkitError.require(parameters, ["parent_path", "type"]) + if err != null: + return err + + var type_str := str(parameters.get("type", "")) + if type_str != "2d" and type_str != "3d": + return MCPToolkitError.fail("INVALID_PARAMS", + "type must be '2d' or '3d', got '%s'" % type_str) + var is_3d := type_str == "3d" + + # --- Preset --- + var preset_name = parameters.get("preset") + var preset := {} + if preset_name != null: + var pn := str(preset_name) + if pn not in _VALID_PRESETS: + return MCPToolkitError.fail("INVALID_PARAMS", + "invalid preset '%s'; valid presets: %s" % [pn, ", ".join(_VALID_PRESETS)]) + preset = _PRESETS[pn].duplicate(true) + if is_3d: + _adjust_for_3d(preset) + + # --- Validate emission_shape --- + var emission_shape_str = parameters.get("emission_shape") + if emission_shape_str != null and str(emission_shape_str) not in _EMISSION_SHAPES: + return MCPToolkitError.fail("INVALID_PARAMS", + "invalid emission_shape '%s'; valid shapes: %s" % [ + str(emission_shape_str), ", ".join(_EMISSION_SHAPES.keys())]) + + # --- Resolve parent --- + var root := Helpers.get_edited_root() + if root == null: + return MCPToolkitError.fail("NO_SCENE", "no edited scene") + var parent_path := str(parameters.get("parent_path", ".")) + parent_path = Helpers.normalize_editor_path(parent_path) + var parent := Helpers.resolve_scene_node(parent_path) + if parent == null: + return MCPToolkitError.fail("NOT_FOUND", + "parent not found: %s" % parent_path, MCPToolkitError.HINT_NODE_PATH) + + # --- Create node + material --- + var node: Node + var default_name := "GPUParticles3D" if is_3d else "GPUParticles2D" + if is_3d: + node = GPUParticles3D.new() + else: + node = GPUParticles2D.new() + node.name = str(parameters.get("name", default_name)) + + var material := ParticleProcessMaterial.new() + var sub_resources: Array = [material] + var properties_set := 0 + var version_notes: Array = [] + + # Build effective config: preset base, user overrides on top. + var eff := preset.duplicate(true) + + # --- Merge user overrides (data-driven; see _OVERRIDE_SPEC) --- + # overrides holds the names of params that shadowed a preset value, in the order + # the response contract expects (simple props, then range params, then emission + # shape). The sub-resource pass appends to it below; the result reads it last. + var overrides: Array = _merge_overrides(eff, parameters) + + # --- Apply node + material properties (data-driven; see _PROP_SPEC) --- + # The uniform single-property rows of pass 7 are applied by _apply_props. + # The range-pair loop and the emission-shape name→enum lookup are NOT uniform + # (weight 2 / value is an enum lookup) and stay explicit below. These writes + # are mutually independent, so the count and final state are order-invariant. + properties_set += _apply_props(node, material, eff) + + # Range params on material (weight 2 each — kept explicit). + for prefix in ["initial_velocity", "damping", "orbit_velocity", "scale", + "angle", "angular_velocity", "hue_variation"]: + if eff.has(prefix + "_min"): + material.set(prefix + "_min", float(eff[prefix + "_min"])) + material.set(prefix + "_max", float(eff[prefix + "_max"])) + properties_set += 2 + + # Emission shape (name → enum lookup — kept explicit). + if eff.has("emission_shape"): + var shape_name := str(eff["emission_shape"]) + if shape_name in _EMISSION_SHAPES: + material.emission_shape = _EMISSION_SHAPES[shape_name] + properties_set += 1 + + # --- Sub-resources: color_ramp, alpha_curve, scale_curve --- + var sr_err = _apply_sub_resource(parameters, eff, preset, material, + "color_ramp", sub_resources, overrides, node) + if sr_err != null: + return sr_err + if material.color_ramp != null: + properties_set += 1 + + sr_err = _apply_sub_resource(parameters, eff, preset, material, + "alpha_curve", sub_resources, overrides, node) + if sr_err != null: + return sr_err + if material.alpha_curve != null: + properties_set += 1 + + sr_err = _apply_sub_resource(parameters, eff, preset, material, + "scale_curve", sub_resources, overrides, node) + if sr_err != null: + return sr_err + if material.scale_curve != null: + properties_set += 1 + + # --- Assign material --- + node.process_material = material + properties_set += 1 + + # --- 3D mesh --- + if is_3d and parameters.has("mesh"): + var mesh_type := str(parameters["mesh"]) + var mesh: Mesh + match mesh_type: + "quad": mesh = QuadMesh.new() + "box": mesh = BoxMesh.new() + "sphere": mesh = SphereMesh.new() + _: mesh = QuadMesh.new() + (node as GPUParticles3D).draw_pass_1 = mesh + sub_resources.append(mesh) + properties_set += 1 + + # --- 2D texture --- + if not is_3d and parameters.has("texture_path"): + var tex_path := str(parameters["texture_path"]) + if ResourceLoader.exists(tex_path): + (node as GPUParticles2D).texture = ResourceLoader.load(tex_path) + properties_set += 1 + + # --- Position --- + if parameters.has("position"): + var pos = parameters["position"] + if is_3d: + (node as Node3D).position = _vec3(pos) + else: + var p = pos if typeof(pos) == TYPE_DICTIONARY else {} + (node as Node2D).position = Vector2( + float(p.get("x", 0.0)), float(p.get("y", 0.0))) + properties_set += 1 + + # --- Version-gated properties --- + var node_class := "GPUParticles3D" if is_3d else "GPUParticles2D" + if parameters.has("use_fixed_seed"): + if _class_has_prop(node_class, "use_fixed_seed"): + node.set("use_fixed_seed", bool(parameters["use_fixed_seed"])) + properties_set += 1 + else: + version_notes.append("use_fixed_seed unavailable in this Godot version") + if parameters.has("seed"): + if _class_has_prop(node_class, "seed"): + node.set("seed", int(parameters["seed"])) + properties_set += 1 + else: + version_notes.append("seed unavailable in this Godot version") + if parameters.has("emission_ring_cone_angle"): + if _class_has_prop("ParticleProcessMaterial", "emission_ring_cone_angle"): + material.set("emission_ring_cone_angle", float(parameters["emission_ring_cone_angle"])) + properties_set += 1 + else: + version_notes.append("emission_ring_cone_angle unavailable in this Godot version") + + # --- Add to scene via UndoRedo --- + parent.add_child(node) + node.set_owner(root) + var _undo := MCPToolkitUndoRedoAction.begin("create %s" % node.name, parent) \ + .do_method(parent.add_child.bind(node)) \ + .do_method(node.set_owner.bind(root)) \ + .do_reference(node) + for res in sub_resources: + _undo.undo_reference(res) + _undo.undo_method(parent.remove_child.bind(node)) \ + .commit_recorded() + + # --- Result --- + var result := { + "node_path": str(root.get_path_to(node)), + "type": "GPUParticles3D" if is_3d else "GPUParticles2D", + "properties_set": properties_set, + "version_notes": version_notes, + } + if preset_name != null: + result["preset_applied"] = str(preset_name) + if not overrides.is_empty(): + result["overrides_applied"] = overrides + return MCPToolkitSuccess.ok(result) + + +static func _apply_sub_resource( + parameters: Dictionary, eff: Dictionary, preset: Dictionary, + material: ParticleProcessMaterial, prop_name: String, + sub_resources: Array, overrides_out: Array, node: Node) -> Variant: + ## Handle color_ramp / alpha_curve / scale_curve. Returns error dict or null. + var source = null + var is_override := false + if parameters.has(prop_name): + source = parameters[prop_name] + is_override = preset.has(prop_name) + elif eff.has(prop_name): + source = eff[prop_name] + if source == null: + return null + + if source is String: + var path := str(source) + if not ResourceLoader.exists(path): + node.free() + return MCPToolkitError.fail("NOT_FOUND", + "%s resource not found: %s" % [prop_name, path]) + material.set(prop_name, ResourceLoader.load(path)) + elif source is Array: + # Preset format: array of point dicts. + if prop_name == "color_ramp": + var result := _build_gradient_texture(source, Gradient.GRADIENT_INTERPOLATE_LINEAR) + material.color_ramp = result[0] + sub_resources.append(result[0]) + sub_resources.append(result[1]) + else: + var result := _build_curve_texture(source) + material.set(prop_name, result[0]) + sub_resources.append(result[0]) + sub_resources.append(result[1]) + elif source is Dictionary and source.has("points"): + # User format: {points: [...], interpolation?}. + if prop_name == "color_ramp": + var interp := Gradient.GRADIENT_INTERPOLATE_LINEAR + var interp_str = source.get("interpolation", "linear") + match str(interp_str): + "cubic": interp = Gradient.GRADIENT_INTERPOLATE_CUBIC + "constant": interp = Gradient.GRADIENT_INTERPOLATE_CONSTANT + var result := _build_gradient_texture(source["points"], interp) + material.color_ramp = result[0] + sub_resources.append(result[0]) + sub_resources.append(result[1]) + else: + var result := _build_curve_texture(source["points"]) + material.set(prop_name, result[0]) + sub_resources.append(result[0]) + sub_resources.append(result[1]) + + if is_override: + overrides_out.append(prop_name) + return null diff --git a/addons/godot_mcp_toolkit/commands/particle_commands.gd.uid b/addons/godot_mcp_toolkit/commands/particle_commands.gd.uid new file mode 100644 index 0000000..cbf33c0 --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/particle_commands.gd.uid @@ -0,0 +1 @@ +uid://danqqvfho7hh4 diff --git a/addons/godot_mcp_toolkit/commands/path_commands.gd b/addons/godot_mcp_toolkit/commands/path_commands.gd new file mode 100644 index 0000000..b6105b6 --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/path_commands.gd @@ -0,0 +1,160 @@ +@tool +extends RefCounted +## path2d.* command handlers — edit Path2D Curve2D points. + +const Modules := preload("res://addons/godot_mcp_toolkit/core/modules.gd") +const Helpers = Modules.CommandHelpers + + +static func register(registry: MCPToolkitCommandRegistry, _server: Node) -> void: + registry.add("path2d.edit_curve", func(parameters: Dictionary) -> Dictionary: + return _cmd_edit_curve2d(parameters) + , MCPToolkitCommandOptions.new()) + + +# -- Commands ----------------------------------------------------------------- + + +## Edits the **Curve2D** on a Path2D scene node (tool `path2d.edit_curve`). NOT +## `procedural.edit_curve`, which authors a 1D **Curve** .tres resource — +## different engine type, different aggregate. The private name carries the +## `2d` suffix so even internal symbols distinguish Curve from Curve2D. +static func _cmd_edit_curve2d(parameters: Dictionary) -> Dictionary: + var err = MCPToolkitError.require(parameters, ["node_path", "action"]) + if err != null: + return err + + var node_path := str(parameters.get("node_path", "")) + var action := str(parameters.get("action", "")) + var points_raw = parameters.get("points", null) + var index_raw = parameters.get("index", null) + + if action not in ["set", "add", "remove", "clear"]: + return MCPToolkitError.fail("INVALID_PARAMS", + "action must be one of: set, add, remove, clear (got '%s')" % action) + + var node = Helpers.resolve_scene_node(node_path) + if node == null: + return MCPToolkitError.fail("NODE_NOT_FOUND", + "no node at path '%s' in edited scene" % node_path) + if not (node is Path2D): + return MCPToolkitError.fail("INVALID_CLASS", + "node at '%s' is not a Path2D (got %s)" % [node_path, node.get_class()]) + + var curve: Curve2D = node.curve + if curve == null: + curve = Curve2D.new() + node.curve = curve + + match action: + "set": + if points_raw == null or typeof(points_raw) != TYPE_ARRAY: + return MCPToolkitError.fail("INVALID_PARAMS", + "action 'set' requires a 'points' array") + return _action_set(node, curve, points_raw as Array) + "add": + if points_raw == null or typeof(points_raw) != TYPE_ARRAY: + return MCPToolkitError.fail("INVALID_PARAMS", + "action 'add' requires a 'points' array") + var index: int = int(index_raw) if index_raw != null else -1 + return _action_add(node, curve, points_raw as Array, index) + "remove": + if index_raw == null: + return MCPToolkitError.fail("INVALID_PARAMS", + "action 'remove' requires an 'index'") + return _action_remove(node, curve, int(index_raw)) + "clear": + return _action_clear(node, curve) + + return MCPToolkitError.fail("INVALID_PARAMS", "unknown action '%s'" % action) + + +# -- Action helpers ----------------------------------------------------------- + + +static func _action_set(node: Path2D, curve: Curve2D, points: Array) -> Dictionary: + # Capture old state for undo. + var old_curve := curve.duplicate() as Curve2D + curve.clear_points() + for i in range(points.size()): + var pt = points[i] + if typeof(pt) != TYPE_DICTIONARY or not pt.has("position"): + return MCPToolkitError.fail("INVALID_PARAMS", + "points[%d] must have a 'position' key with {x, y}" % i) + var pos := _to_vec2(pt["position"]) + var in_h := _to_vec2(pt.get("in_handle")) if pt.has("in_handle") else Vector2.ZERO + var out_h := _to_vec2(pt.get("out_handle")) if pt.has("out_handle") else Vector2.ZERO + curve.add_point(pos, in_h, out_h) + + _wrap_undo_redo(node, curve, old_curve, "MCP: path2d.edit_curve set") + return _ok(curve) + + +static func _action_add(node: Path2D, curve: Curve2D, points: Array, index: int) -> Dictionary: + var old_curve := curve.duplicate() as Curve2D + for i in range(points.size()): + var pt = points[i] + if typeof(pt) != TYPE_DICTIONARY or not pt.has("position"): + return MCPToolkitError.fail("INVALID_PARAMS", + "points[%d] must have a 'position' key with {x, y}" % i) + var pos := _to_vec2(pt["position"]) + var in_h := _to_vec2(pt.get("in_handle")) if pt.has("in_handle") else Vector2.ZERO + var out_h := _to_vec2(pt.get("out_handle")) if pt.has("out_handle") else Vector2.ZERO + if index >= 0: + curve.add_point(pos, in_h, out_h, index + i) + else: + curve.add_point(pos, in_h, out_h) + + _wrap_undo_redo(node, curve, old_curve, "MCP: path2d.edit_curve add") + return _ok(curve) + + +static func _action_remove(node: Path2D, curve: Curve2D, index: int) -> Dictionary: + if index < 0 or index >= curve.point_count: + return MCPToolkitError.fail("INVALID_PARAMS", + "index %d out of range (point_count=%d)" % [index, curve.point_count]) + var old_curve := curve.duplicate() as Curve2D + curve.remove_point(index) + + _wrap_undo_redo(node, curve, old_curve, "MCP: path2d.edit_curve remove") + return _ok(curve) + + +static func _action_clear(node: Path2D, curve: Curve2D) -> Dictionary: + var old_curve := curve.duplicate() as Curve2D + curve.clear_points() + + _wrap_undo_redo(node, curve, old_curve, "MCP: path2d.edit_curve clear") + return _ok(curve) + + +# -- Undo/Redo --------------------------------------------------------------- + + +static func _wrap_undo_redo(node: Path2D, curve: Curve2D, old_curve: Curve2D, action_name: String) -> void: + MCPToolkitUndoRedoAction.begin(action_name.trim_prefix("MCP: "), node) \ + .do_property(node, &"curve", curve) \ + .undo_property(node, &"curve", old_curve) \ + .do_reference(curve) \ + .undo_reference(old_curve) \ + .commit_recorded() + + +# -- Helpers ------------------------------------------------------------------ + + +static func _to_vec2(val) -> Vector2: + if val == null: + return Vector2.ZERO + if val is Vector2: + return val + if typeof(val) == TYPE_DICTIONARY: + return Vector2(float(val.get("x", 0.0)), float(val.get("y", 0.0))) + return Vector2.ZERO + + +static func _ok(curve: Curve2D) -> Dictionary: + return MCPToolkitSuccess.ok({ + "point_count": curve.point_count, + "baked_length": curve.get_baked_length(), + }) diff --git a/addons/godot_mcp_toolkit/commands/path_commands.gd.uid b/addons/godot_mcp_toolkit/commands/path_commands.gd.uid new file mode 100644 index 0000000..c984f48 --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/path_commands.gd.uid @@ -0,0 +1 @@ +uid://damn3101u2ufy diff --git a/addons/godot_mcp_toolkit/commands/playtest/playtest_commands.gd b/addons/godot_mcp_toolkit/commands/playtest/playtest_commands.gd new file mode 100644 index 0000000..e080004 --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/playtest/playtest_commands.gd @@ -0,0 +1,34 @@ +@tool +extends RefCounted +## game.* / debugger.get_log command registration orchestrator — wires the play +## session lifecycle (game.start / game.stop) and the cached debugger.get_log +## reader onto the registry, and injects/tears down the debug bridge. Holds no +## subdomain logic: game.start/stop delegate to the Play-Session Control child, +## debugger.get_log delegates to the Debug-Log Retrieval child, and the bridge +## inject (register) + teardown (clear_debug_bridge) are forwarded to the +## log-reader child that owns the bridge state. + +const _Control := preload("res://addons/godot_mcp_toolkit/commands/playtest/playtest_control.gd") +const _LogReader := preload("res://addons/godot_mcp_toolkit/commands/playtest_log_reader.gd") + + +static func register(registry: MCPToolkitCommandRegistry, _server: Node, + debug_bridge: RefCounted = null) -> void: + _LogReader.set_debug_bridge(debug_bridge) + # game.start / game.stop mutate (launch / stop a play session), so they are + # NOT read-only. Exclusive-execution is the sole serialization driver: it + # forces single-in-flight ordering regardless of read-only status, which is + # what these session-lifecycle mutators require. + registry.add("game.start", func(parameters: Dictionary) -> Dictionary: + return await _Control.cmd_game_start(parameters) + , MCPToolkitCommandOptions.new().mark_exclusive_execution()) + registry.add("game.stop", func(parameters: Dictionary) -> Dictionary: + return _Control.cmd_game_stop(parameters) + , MCPToolkitCommandOptions.new().mark_exclusive_execution().mark_scene_independent()) + registry.add("debugger.get_log", func(parameters: Dictionary) -> Dictionary: + return _LogReader.cmd_debugger_get_log(parameters) + , MCPToolkitCommandOptions.new().mark_read_only().mark_scene_independent()) + + +static func clear_debug_bridge() -> void: + _LogReader.clear_debug_bridge() diff --git a/addons/godot_mcp_toolkit/commands/playtest/playtest_commands.gd.uid b/addons/godot_mcp_toolkit/commands/playtest/playtest_commands.gd.uid new file mode 100644 index 0000000..dff7ac6 --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/playtest/playtest_commands.gd.uid @@ -0,0 +1 @@ +uid://brw700knbxc3v diff --git a/addons/godot_mcp_toolkit/commands/playtest/playtest_control.gd b/addons/godot_mcp_toolkit/commands/playtest/playtest_control.gd new file mode 100644 index 0000000..4c60978 --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/playtest/playtest_control.gd @@ -0,0 +1,365 @@ +@tool +extends RefCounted +## Play-Session Control (editor side): start or stop the editor playtest and +## confirm the live runtime's readiness. +## +## Owns the game.start / game.stop handlers (Mode A — start/stop a play session +## via EditorInterface.play_*/stop_playing_scene), the runtime-readiness probe +## (a WebSocket connect→auth→ping health-check over the live game's Mode-B +## server), and start-failure diagnosis (a LogBuffer scan that explains why the +## game never started). game.start marks the debug-log session as started by +## calling the log-reader's public mark_session_started() — the one-datum +## handoff to the Debug-Log Retrieval child. +## An extracted submodule of the playtest-command group, reached via a `preload` alias. + +const Modules := preload("res://addons/godot_mcp_toolkit/core/modules.gd") +const FileGuard = Modules.FileGuard +const RegistryClient = Modules.RegistryClient +const Helpers = Modules.CommandHelpers +const MCPAuth := preload("res://addons/godot_mcp_toolkit/security/auth.gd") +const _LogReader := preload("res://addons/godot_mcp_toolkit/commands/playtest_log_reader.gd") + +const RUNTIME_HOST := "127.0.0.1" +const RUNTIME_POLL_TIMEOUT_MS := 5000 +const _REGISTRY_POLL_INTERVAL_MS := 100 + + +# -- Commands ----------------------------------------------------------------- + + +static func cmd_game_start(parameters: Dictionary) -> Dictionary: + # A headless editor cannot launch the game process, so the runtime (Mode B) never + # comes up — the pre-guard success:true was a false-success (nothing stays playing). + # Fail deterministically like editor.screenshot so an LLM branches instead of polling + # a runtime that will never connect. Display path is byte-identical below the guard. + if Modules.VersionUtils.is_headless(): + return MCPToolkitError.fail("HEADLESS_UNSUPPORTED", + "playtest requires a display server (the game process cannot be launched headless) — use script_check, scene/node inspection, or editor_get_console for verification.") + + var target := str(parameters.get("scene_path", "current")) + var wait_for_runtime_raw = parameters.get("wait_for_runtime", true) + var wait_for_runtime := bool(wait_for_runtime_raw) \ + if typeof(wait_for_runtime_raw) == TYPE_BOOL else true + var runtime_poll_raw = parameters.get("runtime_poll", false) + var runtime_poll := bool(runtime_poll_raw) \ + if typeof(runtime_poll_raw) == TYPE_BOOL else false + var if_running := str(parameters.get("if_running", "fail")) + + if not (if_running in ["return", "fail"]): + return MCPToolkitError.fail("INVALID_PARAMS", + "if_running must be 'return' or 'fail' (got %s); default is 'fail'" % if_running) + + if runtime_poll: + if not EditorInterface.is_playing_scene(): + # runtime_poll never launches the game (probe-only), so reaching here means + # nothing was running to probe. An empty buffer can't prove a compile failure, + # so diagnose by captured evidence rather than asserting a cause. + var comp := _scan_compilation_errors() + var diag := _diagnose_startup(comp, false, Modules.VersionUtils.get_engine_version_pair()) + return MCPToolkitError.fail(diag["code"], diag["message"], diag["hint"]) + else: + if EditorInterface.is_playing_scene(): + if if_running == "return": + var runtime_port := RegistryClient.get_runtime_port() + return MCPToolkitSuccess.ok({"status": "already_running", + "runtime_port": runtime_port if runtime_port > 0 else null}) + return MCPToolkitError.fail("ALREADY_PLAYING", + "a game is already running; call game.stop first, or use runtime_poll:true to re-probe the runtime connection") + + # Mark that a play session has started so the editor-side debugger.get_log + # cache reads the game's log. The engine truncates godot.log fresh on every + # launch, so the whole file is this session (the reader reads from byte 0). + _LogReader.mark_session_started() + + match target: + "main": + # Pre-guard: play_main_scene() with no main scene defined pops an + # undismissable "No main scene has ever been defined" modal and returns, + # leaving the plugin to falsely report success (a later runtime_poll then + # misreads it as COMPILATION_FAILED). Fail deterministically instead — + # mirrors the "current" branch's NO_SCENE guard. + if _main_scene_missing(): + return MCPToolkitError.fail("NO_SCENE", + "no main scene set — set one in Project Settings > Application > Run > Main Scene " + + "(or project_set_setting application/run/main_scene='res://YourScene.tscn'), " + + "or use target:'current' or a res:// scene path.") + EditorInterface.play_main_scene() + "current": + if Helpers.get_edited_root() == null: + return MCPToolkitError.fail("NO_SCENE", + "no currently-edited scene; use target:'main' or target:, or scene.open first") + EditorInterface.play_current_scene() + _: + var guard := FileGuard.resolve_safe(target) + if guard["error"] != null: + return MCPToolkitError.fail("PATH_DENIED", str(guard["reason"])) + if target.get_extension().to_lower() != "tscn": + return MCPToolkitError.fail("INVALID_PATH", + "game.start only plays .tscn files (got %s)" % target) + if not FileAccess.file_exists(target): + return MCPToolkitError.fail("NOT_FOUND", + "no scene file at %s; use scene.create first" % target, MCPToolkitError.HINT_FILE_PATH) + EditorInterface.play_custom_scene(target) + + # Runtime readiness check. + var runtime_port := -1 + var runtime_ready := false + var runtime_failure := "" + var bridge_discovery := false + + if runtime_poll: + # Explicit re-probe: full poll loop (runtime_poll:true path). + var deadline := Time.get_ticks_msec() + RUNTIME_POLL_TIMEOUT_MS + while Time.get_ticks_msec() < deadline: + runtime_port = RegistryClient.get_runtime_port() + if runtime_port > 0: + break + await Engine.get_main_loop().create_timer(_REGISTRY_POLL_INTERVAL_MS / 1000.0).timeout + if runtime_port > 0: + var remaining := maxi(500, deadline - Time.get_ticks_msec()) + var probe := await _poll_runtime_ready( + RUNTIME_HOST, runtime_port, remaining) + runtime_ready = probe["ready"] + if not runtime_ready: + runtime_failure = str(probe.get("failure", "unknown")) + else: + runtime_failure = "registry_timeout" + elif wait_for_runtime: + # Godot's editor is single-threaded: play_custom_scene() defers + # game launch to the event loop, so blocking polls can never + # succeed here (main thread blocked → game cannot spawn). + # The agent must follow up with: + # game_start(if_running:"return", runtime_poll:true) + bridge_discovery = true + + var response := MCPToolkitSuccess.ok({ + "target": target, + "runtime_port": runtime_port if runtime_port > 0 else null, + "runtime_ready": runtime_ready, + }) + if bridge_discovery: + response["runtime_discovery"] = "bridge" + # Hint text suppressed when wait_for_runtime=true — the MCP server + # absorbs the async gap and returns a single combined response. + # Non-server clients can key off runtime_discovery:"bridge" to follow + # up with game_start(if_running:'return', runtime_poll:true). + if not wait_for_runtime: + response["hint"] = ( + "Game launched but runtime not yet connected (Godot defers the " + + "game process — it cannot start during a blocking call). " + + "Follow up with game_start(if_running:'return', runtime_poll:true) " + + "to wait for runtime readiness." + ) + if runtime_poll: + response["runtime_poll"] = true + if (wait_for_runtime or runtime_poll) and not runtime_ready and not bridge_discovery: + response["runtime_failure"] = runtime_failure + match runtime_failure: + "registry_timeout": + if not EditorInterface.is_playing_scene(): + # The game was playing when the poll began but has stopped — a + # ran-then-died soft path, framed as a runtime crash (it began + # running, so a compile error is unlikely). runtime_ready:false + + # runtime_failure:"registry_timeout" are the honest structured signals. + var comp := _scan_compilation_errors() + var diag := _diagnose_startup(comp, true, Modules.VersionUtils.get_engine_version_pair()) + response["hint"] = diag["hint"] + if not (diag["errors"] as Array).is_empty(): + response["compilation_errors"] = diag["errors"] + else: + response["hint"] = "Runtime port never appeared in registry within the timeout. The game may need more time to start. Try game_start with runtime_poll:true to re-probe, or check editor_get_console for startup errors." + "token_read_failed": + response["hint"] = "Could not read auth token — the token file may be missing or empty. Re-enable the plugin in Project Settings > Plugins." + "ws_connect_timeout": + response["hint"] = "Port %d found but WebSocket connection failed — the runtime server may have crashed on startup. Check editor_get_console for errors." % runtime_port + "auth_timeout": + response["hint"] = "WebSocket connected but auth handshake timed out — the token may be stale. Re-enable the plugin in Project Settings > Plugins to regenerate it." + "ping_timeout": + response["hint"] = "Authenticated but ping/pong timed out — the runtime server may be overloaded. Use runtime_poll:true to retry without restarting the game." + _: + response["hint"] = "Runtime not ready (%s). Checklist: (1) Is the MCP Runtime autoload enabled? (2) Is the runtime port (default 6570) available? (3) Check editor_get_console for errors. (4) If runtime tools aren't loaded yet, call discover_tools({request: 'runtime'})." % runtime_failure + # P-006: When no poll was requested and runtime isn't ready, warn that + # runtime tools will block/fail. Prevents agents from calling + # runtime_screenshot / input_simulate on a game that didn't connect. + elif not runtime_ready and not wait_for_runtime and not runtime_poll: + response["hint"] = ( + "runtime_ready is false — runtime tools (runtime_screenshot, input_simulate, " + + "execute_code, etc.) will NOT work until the runtime connects. " + + "Call game_start with wait_for_runtime:true to wait for connection, " + + "or use runtime_poll:true to re-probe. " + + "Check debugger_get_log or editor_get_console for startup errors." + ) + return response + + +## True when no main scene is configured (application/run/main_scene unset or blank). +## Read FRESH from ProjectSettings — not a cached value — so a just-written setting is +## honored. Backs the game.start "main" pre-guard: fail deterministically rather than +## let play_main_scene() pop the engine's undismissable "No main scene has ever been +## defined" modal (which returns silently → a false success). Pure query; unit-tested. +static func _main_scene_missing() -> bool: + return str(ProjectSettings.get_setting("application/run/main_scene", "")).strip_edges().is_empty() + + +static func cmd_game_stop(_parameters: Dictionary) -> Dictionary: + var was_running := EditorInterface.is_playing_scene() + EditorInterface.stop_playing_scene() + return MCPToolkitSuccess.ok({"was_running": was_running}) + + +# -- Runtime probe ------------------------------------------------------------ + + +## WebSocket health-check: connect, authenticate, send ping, wait for response. +## Only reports true when the full JSON-RPC layer is operational (no false +## positives from a TCP-only probe). +## Returns {"ready": true} on success, or {"ready": false, "failure": } +## where stage is one of: token_read_failed, ws_connect_timeout, auth_timeout, +## ping_timeout. +static func _poll_runtime_ready( + host: String, port: int, timeout_ms: int, +) -> Dictionary: + var token_path := MCPAuth.get_token_path() + var token_file := FileAccess.open(token_path, FileAccess.READ) + if token_file == null: + return {"ready": false, "failure": "token_read_failed"} + var token := token_file.get_as_text().strip_edges() + token_file.close() + if token.is_empty(): + return {"ready": false, "failure": "token_read_failed"} + + var furthest := "ws_connect_timeout" + var deadline := Time.get_ticks_msec() + timeout_ms + while Time.get_ticks_msec() < deadline: + var ws := WebSocketPeer.new() + var err := ws.connect_to_url("ws://%s:%d" % [host, port]) + if err != OK: + await Engine.get_main_loop().create_timer(0.1).timeout + continue + + var auth_sent := false + var ping_sent := false + while Time.get_ticks_msec() < deadline: + ws.poll() + var state := ws.get_ready_state() + if state == WebSocketPeer.STATE_CLOSED \ + or state == WebSocketPeer.STATE_CLOSING: + break + if state != WebSocketPeer.STATE_OPEN: + await Engine.get_main_loop().create_timer(0.01).timeout + continue + + if furthest == "ws_connect_timeout": + furthest = "auth_timeout" + + if not auth_sent: + ws.send_text(JSON.stringify({"auth": token})) + auth_sent = true + await Engine.get_main_loop().create_timer(0.01).timeout + continue + + while ws.get_available_packet_count() > 0: + var text := ws.get_packet().get_string_from_utf8() + var parser := JSON.new() + if parser.parse(text) != OK: + continue + var msg = parser.data + if typeof(msg) != TYPE_DICTIONARY: + continue + if not ping_sent: + if msg.get("authed", false) == true: + ws.send_text(JSON.stringify({ + "jsonrpc": "2.0", "method": "ping", "id": 0})) + ping_sent = true + furthest = "ping_timeout" + else: + if msg.has("result"): + ws.close(1000) + return {"ready": true} + + await Engine.get_main_loop().create_timer(0.01).timeout + + ws.close() + await Engine.get_main_loop().create_timer(0.1).timeout + return {"ready": false, "failure": furthest} + + +## Scan LogBuffer for recent errors — used to detect compilation failures +## when the game fails to start. +static func _scan_compilation_errors() -> Dictionary: + var buf := Modules.LogBuffer.get_entries(10, ["error"]) + var errors: Array = [] + for entry in buf.get("entries", []): + errors.append(str(entry.get("message", ""))) + return { + "found": errors.size() > 0, + "errors": errors, + "source": "logger" if Modules.LogBuffer.uses_logger_api() else "file_tail", + } + + +## Phase-aware diagnosis of why a runtime probe found no live game, given the +## log-scan result and whether the game was observed running. +## +## Pure over its inputs so the whole decision is headless-unit-testable. Takes the +## [param comp] scan dict from [method _scan_compilation_errors] +## ([code]{"found", "errors", "source"}[/code]), [param was_running] (true when the +## game was seen playing this call — the soft "ran-then-died" path, false when +## nothing was ever confirmed running — the hard "never-started" path), and the +## running-engine [param version_pair] (e.g. [code]"4.5"[/code]) for the file-tail +## caveat. Returns [code]{"code", "message", "hint", "errors"}[/code].[br] +## [br] +## The framing only claims a compile failure when errors are actually in hand +## ([code]found[/code]) AND the game never ran: an empty buffer proves nothing about +## compilation (a cold-start parse error may not have landed, and 4.2–4.4 file-tail +## mode can miss parse errors entirely), so an empty buffer states the +## [code]GAME_NOT_RUNNING[/code] fact and offers both hypotheses rather than asserting +## a cause. A game that ran then stopped is framed as a runtime crash, not a compile +## error (it began running). +static func _diagnose_startup( + comp: Dictionary, was_running: bool, version_pair: String, +) -> Dictionary: + # comp[...] is Variant at the JSON-shaped scan boundary → coerce, never infer. + var found := bool(comp.get("found", false)) + var errors: Array = comp.get("errors", []) + var is_file_tail := str(comp.get("source", "")) == "file_tail" + # The file-tail signal is version-gated (4.2–4.4); name the version only there so + # the agent knows the empty buffer may be a capture limit, not a clean startup. + var file_tail_caveat := ( + " (Godot %s tails the log file and can miss compile errors.)" % version_pair + if is_file_tail else "" + ) + + if found: + var joined := "\n".join(errors) + if was_running: + return { + "code": "GAME_NOT_RUNNING", + "message": "Game stopped mid-probe with errors captured — likely a runtime crash:\n" + joined, + "hint": "The game began running, so a compile error is unlikely. Check debugger_get_log or editor_get_console for the crash." + file_tail_caveat, + "errors": errors, + } + return { + "code": "COMPILATION_FAILED", + "message": "Game failed to start — likely a startup or parse error. Recent errors:\n" + joined, + "hint": "Fix the errors shown, then call game_start again." + file_tail_caveat, + "errors": errors, + } + + if was_running: + return { + "code": "GAME_NOT_RUNNING", + "message": "Game started then stopped before the probe completed.", + "hint": "Likely a runtime crash or early exit (it began running, so a compile error is unlikely). Check debugger_get_log or editor_get_console." + file_tail_caveat, + "errors": errors, + } + return { + "code": "GAME_NOT_RUNNING", + "message": "No running game to probe.", + "hint": ( + "Either (a) nothing launched yet / still cold-starting — call game_start (without runtime_poll), " + + "or retry runtime_poll:true shortly; or (b) it failed to compile. To surface a compile error not " + + "in the buffer, editor_refresh then editor_get_console." + file_tail_caveat + ), + "errors": errors, + } diff --git a/addons/godot_mcp_toolkit/commands/playtest/playtest_control.gd.uid b/addons/godot_mcp_toolkit/commands/playtest/playtest_control.gd.uid new file mode 100644 index 0000000..51a8aa7 --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/playtest/playtest_control.gd.uid @@ -0,0 +1 @@ +uid://dfo1erycmxq0c diff --git a/addons/godot_mcp_toolkit/commands/playtest_log_reader.gd b/addons/godot_mcp_toolkit/commands/playtest_log_reader.gd new file mode 100644 index 0000000..0866387 --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/playtest_log_reader.gd @@ -0,0 +1,246 @@ +@tool +extends RefCounted +## Debug-log retrieval (editor side): the cached debugger.get_log reader. +## +## Reads the most-recent play session's output from the engine log FILE +## (user://logs/godot.log — written by the game/play process; the editor process +## does not write it, engine !editor guard). The engine truncates that file fresh +## on every launch (RotatedFileLogger rotates the prior log to a timestamped backup +## and reopens the base path with FileAccess::WRITE), so the file is always exactly +## the current session starting at byte 0 — this reader reads the whole file. +## ANSI-strips, text-filters, and limits the lines; when an EditorDebuggerPlugin +## bridge is present, merges its debug_state + error_buffer (falling back to a +## log-line error scan) into one crash-context response. +## +## Owns the two pieces of state this subdomain depends on: +## - the session-started flag + the public mark_session_started() setter the +## Play-control side calls at game.start (so a read before any game.start +## returns "no session yet" rather than a prior editor session's stale log); +## - the injected _debug_bridge (the EditorDebuggerPlugin, shared upstream with +## debug_commands.gd) + set_debug_bridge()/clear_debug_bridge(). +## An extracted submodule of the playtest-command group, reached via a `preload` alias. + +const Modules := preload("res://addons/godot_mcp_toolkit/core/modules.gd") +const Helpers = Modules.CommandHelpers +const LogHelpers = Modules.LogHelpers + +# True once game.start has launched a play session this editor session. The engine +# truncates user://logs/godot.log fresh on every launch (RotatedFileLogger ctor → +# rotate_file → FileAccess::WRITE; source-verified 4.2–4.7), so once a session has +# started the whole file IS the current session and the reader reads from byte 0. +# Before the first game.start it stays false, so debugger.get_log reports "no +# session yet" rather than a prior editor session's stale log. The file is written +# by the game/play process (the editor does not write it — engine !editor guard), +# so reading it gives game output even though the Logger API is process-local (4.5+). +static var _game_session_started: bool = false + +# Debug bridge reference — injected at register() time. Supplies the +# error_buffer + debug_state fields of the debugger log payload. +static var _debug_bridge: RefCounted = null + + +# -- Session-start handoff --------------------------------------------------- + + +## Mark that a play session has started — debugger.get_log then reads the whole +## godot.log. The engine truncates that file fresh on every launch, so the file is +## exactly the current session (read from byte 0). Called by Play-control +## (game.start) the moment a play session launches. +static func mark_session_started() -> void: + _game_session_started = true + + +# -- Debug-bridge injection --------------------------------------------------- + + +static func set_debug_bridge(bridge: RefCounted) -> void: + _debug_bridge = bridge + + +static func clear_debug_bridge() -> void: + _debug_bridge = null + + +# -- Commands ----------------------------------------------------------------- + + +## Editor-side debugger.get_log — returns cached log entries from the most +## recent game session. Reads from the LOG FILE (written by the game/play +## process — the editor does not write it) rather than the in-memory LogBuffer +## (which is process-local and only captures editor output on 4.5+). +## When a debug bridge is available, merges the error buffer and debug state +## into the response — gives the server a single call for full crash context. +static func cmd_debugger_get_log(parameters: Dictionary) -> Dictionary: + var limit: int = max(1, int(parameters.get("limit", 200))) + var text_filter: String = str(parameters.get("text_filter", "")) + var tf := Helpers.compile_text_filter(parameters) + var text_regex: RegEx = tf[0] + if tf[1] != null: + return tf[1] + var regex_warning: String = tf[2] + + if not _game_session_started: + var response := MCPToolkitSuccess.ok(_empty_log_page( + "No game session recorded yet (game_start was never called this editor session)")) + _merge_debug_bridge_data(response) + return response + + # Read the current session's log file (the engine truncated it fresh at launch). + var log_path: String = LogHelpers.resolve_log_path() + if not FileAccess.file_exists(log_path): + var response := MCPToolkitSuccess.ok(_empty_log_page( + "Log file not found. Enable debug/file_logging/enable_file_logging in ProjectSettings and restart.")) + _merge_debug_bridge_data(response) + return response + + var file := FileAccess.open(log_path, FileAccess.READ) + if file == null: + return MCPToolkitError.fail("LOG_BUSY", + "log file exists but could not be read (err %d)" % FileAccess.get_open_error(), + MCPToolkitError.log_busy_hint(Modules.LogBuffer.uses_logger_api())) + + var file_len: int = file.get_length() + if file_len <= 0: + file.close() + var response := MCPToolkitSuccess.ok(_empty_log_page( + "No game output yet (log file is empty).")) + _merge_debug_bridge_data(response) + return response + + # The engine truncates godot.log fresh on every launch (see mark_session_started), + # so the whole file is this session — read it all from byte 0. + var new_bytes := file.get_buffer(file_len) + file.close() + + var new_text := new_bytes.get_string_from_utf8() + var all_lines := new_text.split("\n", false) + + # Strip ANSI from all lines once (used for both filtering and error scan). + var stripped_lines: Array = [] + for line in all_lines: + var stripped := LogHelpers.strip_ansi(line.strip_edges()) + if not stripped.is_empty(): + stripped_lines.append(stripped) + + # Apply text filter. + var filtered: Array = [] + for stripped in stripped_lines: + if text_filter != "": + if text_regex != null: + if not text_regex.search(stripped): + continue + else: + if stripped.findn(text_filter) < 0: + continue + filtered.append(stripped) + + # Apply limit (take last N lines). + # Capture the pre-slice filtered count for total_lines before the + # slice reassigns `filtered` to the capped tail. + var total_lines := filtered.size() + var truncated := filtered.size() > limit + if truncated: + filtered = filtered.slice(filtered.size() - limit) + + # Capped-tail pagination (oldest lines drop first, no cursor) — advise raising + # limit / text_filter rather than a cursor. + var hint := "" + if truncated: + hint = "more lines remain — raise limit or narrow with text_filter (capped tail: the oldest lines drop first, no cursor)." + var response := MCPToolkitSuccess.ok(Modules.Pagination.build( + {"lines": filtered}, "lines", total_lines, filtered.size(), truncated, + "", 0, hint, {"source": "cache"})) + # Pass unfiltered lines so error scan always has full context. + _merge_debug_bridge_data(response, stripped_lines) + if not regex_warning.is_empty(): + response["warning"] = regex_warning + return response + + +# -- Helpers ------------------------------------------------------------------ + + +## The empty cached-log page returned when no session output is available. +## [param note] explains which of the no-output conditions applies. Stamps the +## standard pagination invariants (empty, nothing more) so the shape matches a +## populated read. +static func _empty_log_page(note: String) -> Dictionary: + return Modules.Pagination.build({"lines": []}, "lines", 0, 0, false, "", 0, "", + {"source": "cache", "note": note}) + + +## Shared error-entry shape for debug_state/error_buffer responses. +## timestamp_ms + entry_type vary by source (live capture vs log scan); all +## other fields are pass-through. Explicit types so the builder never :=-infers +## from a Variant — message/source/function are str()-typed at the call sites. +static func make_error_entry(timestamp_ms: int, message: String, source: String, + function: String, line: int, entry_type: String) -> Dictionary: + return { + "timestamp_ms": timestamp_ms, + "message": message, + "source": source, + "function": function, + "line": line, + "type": entry_type, + } + + +## Merge debug bridge error buffer + state into a debugger.get_log response. +## all_lines: unfiltered log lines — error scan needs adjacent "at:" lines +## that text_filter might exclude. +static func _merge_debug_bridge_data(response: Dictionary, + all_lines: Array = []) -> void: + if _debug_bridge == null: + return + response["debug_state"] = _debug_bridge.get_debug_state() + var buf: Array = _debug_bridge.get_error_buffer() + # Fallback: if _capture didn't fire (Godot's built-in debugger handles + # "error" messages before plugins see them), scan log lines for errors. + if buf.is_empty() and not all_lines.is_empty(): + buf = _scan_lines_for_errors(all_lines) + if not buf.is_empty(): + response["error_buffer"] = buf + + +## Scan log lines for error patterns and build synthetic error_buffer entries. +## Godot error lines follow the pattern: +## "USER SCRIPT ERROR: " (script errors) +## "SCRIPT ERROR: " (engine script errors) +## " at: (:)" (source location, follows the error) +## This fallback populates error_buffer when _capture doesn't fire. +static func _scan_lines_for_errors(lines: Array) -> Array: + var errors: Array = [] + var i := 0 + while i < lines.size(): + var line: String = str(lines[i]) + var msg := "" + if line.begins_with("USER SCRIPT ERROR:"): + msg = line.substr(len("USER SCRIPT ERROR:")).strip_edges() + elif line.begins_with("SCRIPT ERROR:"): + msg = line.substr(len("SCRIPT ERROR:")).strip_edges() + elif line.begins_with("ERROR:"): + msg = line.substr(len("ERROR:")).strip_edges() + if not msg.is_empty(): + var source := "" + var func_name := "" + var source_line := 0 + # Check the next line for " at: func (file:line)" pattern. + if i + 1 < lines.size(): + var next: String = str(lines[i + 1]).strip_edges() + if next.begins_with("at:"): + var at_info := next.substr(len("at:")).strip_edges() + var paren_open := at_info.rfind("(") + var paren_close := at_info.rfind(")") + if paren_open >= 0 and paren_close > paren_open: + func_name = at_info.left(paren_open).strip_edges() + var loc := at_info.substr(paren_open + 1, + paren_close - paren_open - 1) + var colon := loc.rfind(":") + if colon >= 0: + source = loc.left(colon) + source_line = int(loc.substr(colon + 1)) + i += 1 # Skip the "at:" line + errors.append(make_error_entry( + 0, msg, source, func_name, source_line, "log_scan")) + i += 1 + return errors diff --git a/addons/godot_mcp_toolkit/commands/playtest_log_reader.gd.uid b/addons/godot_mcp_toolkit/commands/playtest_log_reader.gd.uid new file mode 100644 index 0000000..56569a0 --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/playtest_log_reader.gd.uid @@ -0,0 +1 @@ +uid://erwvhrcseuex diff --git a/addons/godot_mcp_toolkit/commands/procedural_commands.gd b/addons/godot_mcp_toolkit/commands/procedural_commands.gd new file mode 100644 index 0000000..53356d2 --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/procedural_commands.gd @@ -0,0 +1,355 @@ +@tool +extends RefCounted +## procedural.* command handlers — create/edit Gradient, Curve, and +## FastNoiseLite resources as standalone .tres files. + +const Modules := preload("res://addons/godot_mcp_toolkit/core/modules.gd") +const Coerce = Modules.Coerce +const FileGuard = Modules.FileGuard +const Helpers = Modules.CommandHelpers + + +static func register(registry: MCPToolkitCommandRegistry, _server: Node) -> void: + registry.add("procedural.edit_gradient", func(parameters: Dictionary) -> Dictionary: + return await _cmd_edit_gradient(parameters) + , MCPToolkitCommandOptions.new().mark_scene_independent()) + registry.add("procedural.edit_curve", func(parameters: Dictionary) -> Dictionary: + return await _cmd_edit_curve(parameters) + , MCPToolkitCommandOptions.new().mark_scene_independent()) + registry.add("procedural.edit_noise", func(parameters: Dictionary) -> Dictionary: + return await _cmd_edit_noise(parameters) + , MCPToolkitCommandOptions.new().mark_scene_independent()) + + +# -- Commands ----------------------------------------------------------------- + + +static func _cmd_edit_gradient(parameters: Dictionary) -> Dictionary: + var err = MCPToolkitError.require(parameters, ["file_path"]) + if err != null: + return err + + var file_path := str(parameters.get("file_path", "")) + file_path = Helpers.normalize_editor_path(file_path) + var action := str(parameters.get("action", "set")) + var points_raw = parameters.get("points", null) + var index_raw = parameters.get("index", null) + var interp_raw = parameters.get("interpolation_mode", null) + + var guard := FileGuard.resolve_safe(file_path) + if guard["error"] != null: + return MCPToolkitError.fail("PATH_DENIED", str(guard["reason"])) + if file_path.get_extension().to_lower() != "tres": + return MCPToolkitError.fail("INVALID_PATH", + "procedural.edit_gradient writes .tres files (got %s)" % file_path) + + if action not in ["set", "add_point", "remove_point"]: + return MCPToolkitError.fail("INVALID_PARAMS", + "action must be one of: set, add_point, remove_point (got '%s')" % action) + + # Load or create Gradient. + var gradient: Gradient + if FileAccess.file_exists(file_path): + var loaded = ResourceLoader.load(file_path, "", ResourceLoader.CACHE_MODE_IGNORE) + if loaded == null or not (loaded is Gradient): + return MCPToolkitError.fail("INVALID_CLASS", + "Resource at %s is not a Gradient" % file_path) + gradient = loaded as Gradient + else: + gradient = Gradient.new() + + match action: + "set": + if points_raw == null or typeof(points_raw) != TYPE_ARRAY: + return MCPToolkitError.fail("INVALID_PARAMS", + "action 'set' requires a 'points' array") + var points := points_raw as Array + # Clear existing points (Gradient always keeps at least 1). + while gradient.get_point_count() > 1: + gradient.remove_point(gradient.get_point_count() - 1) + # Set the first point, then add the rest. + if points.size() > 0: + gradient.set_offset(0, float(points[0].get("offset", 0.0))) + gradient.set_color(0, _color(points[0].get("color", {}))) + for i in range(1, points.size()): + gradient.add_point( + float(points[i].get("offset", 0.0)), + _color(points[i].get("color", {}))) + "add_point": + if points_raw == null or typeof(points_raw) != TYPE_ARRAY: + return MCPToolkitError.fail("INVALID_PARAMS", + "action 'add_point' requires a 'points' array with at least one entry") + var points := points_raw as Array + if points.size() < 1: + return MCPToolkitError.fail("INVALID_PARAMS", + "action 'add_point' requires at least one point") + gradient.add_point( + float(points[0].get("offset", 0.0)), + _color(points[0].get("color", {}))) + "remove_point": + if index_raw == null: + return MCPToolkitError.fail("INVALID_PARAMS", + "action 'remove_point' requires an 'index'") + var index := int(index_raw) + if index < 0 or index >= gradient.get_point_count(): + return MCPToolkitError.fail("INVALID_PARAMS", + "index %d out of range (point_count=%d)" % [index, gradient.get_point_count()]) + gradient.remove_point(index) + + # Interpolation mode. + if interp_raw != null: + var interp_str := str(interp_raw) + var interp_map := {"linear": 0, "constant": 1, "cubic": 2} + if not interp_map.has(interp_str): + return MCPToolkitError.fail("INVALID_PARAMS", + "interpolation_mode must be one of: linear, constant, cubic (got '%s')" % interp_str) + gradient.interpolation_mode = interp_map[interp_str] + + return await _save_resource(gradient, file_path, "procedural.edit_gradient", + {"point_count": gradient.get_point_count()}) + + +## Authors a 1D **Curve** resource as a standalone .tres file (tool +## `procedural.edit_curve`). NOT `path2d.edit_curve`, which edits a **Curve2D** +## on a Path2D scene node — different engine type, different aggregate. +static func _cmd_edit_curve(parameters: Dictionary) -> Dictionary: + var err = MCPToolkitError.require(parameters, ["file_path"]) + if err != null: + return err + + var file_path := str(parameters.get("file_path", "")) + file_path = Helpers.normalize_editor_path(file_path) + var action := str(parameters.get("action", "set")) + var points_raw = parameters.get("points", null) + var index_raw = parameters.get("index", null) + var min_val = parameters.get("min_value", null) + var max_val = parameters.get("max_value", null) + + var guard := FileGuard.resolve_safe(file_path) + if guard["error"] != null: + return MCPToolkitError.fail("PATH_DENIED", str(guard["reason"])) + if file_path.get_extension().to_lower() != "tres": + return MCPToolkitError.fail("INVALID_PATH", + "procedural.edit_curve writes .tres files (got %s)" % file_path) + + if action not in ["set", "add_point", "remove_point", "clear"]: + return MCPToolkitError.fail("INVALID_PARAMS", + "action must be one of: set, add_point, remove_point, clear (got '%s')" % action) + + # Load or create Curve. + var curve: Curve + if FileAccess.file_exists(file_path): + var loaded = ResourceLoader.load(file_path, "", ResourceLoader.CACHE_MODE_IGNORE) + if loaded == null or not (loaded is Curve): + return MCPToolkitError.fail("INVALID_CLASS", + "Resource at %s is not a Curve" % file_path) + curve = loaded as Curve + else: + curve = Curve.new() + + # Set min/max before adding points. + if min_val != null: + curve.min_value = float(min_val) + if max_val != null: + curve.max_value = float(max_val) + + match action: + "set": + if points_raw == null or typeof(points_raw) != TYPE_ARRAY: + return MCPToolkitError.fail("INVALID_PARAMS", + "action 'set' requires a 'points' array") + curve.clear_points() + for i in range((points_raw as Array).size()): + var pt = (points_raw as Array)[i] + if typeof(pt) != TYPE_DICTIONARY or not pt.has("position"): + return MCPToolkitError.fail("INVALID_PARAMS", + "points[%d] must have a 'position' key with {x, y}" % i) + var pos_d = pt["position"] + var pos := Vector2(float(pos_d.get("x", 0.0)), float(pos_d.get("y", 0.0))) + var lt := float(pt.get("left_tangent", 0.0)) + var rt := float(pt.get("right_tangent", 0.0)) + var lm := _tangent_mode(pt.get("left_mode", null)) + var rm := _tangent_mode(pt.get("right_mode", null)) + curve.add_point(pos, lt, rt, lm, rm) + "add_point": + if points_raw == null or typeof(points_raw) != TYPE_ARRAY: + return MCPToolkitError.fail("INVALID_PARAMS", + "action 'add_point' requires a 'points' array with at least one entry") + var points := points_raw as Array + if points.size() < 1: + return MCPToolkitError.fail("INVALID_PARAMS", + "action 'add_point' requires at least one point") + var pt = points[0] + if typeof(pt) != TYPE_DICTIONARY or not pt.has("position"): + return MCPToolkitError.fail("INVALID_PARAMS", + "points[0] must have a 'position' key with {x, y}") + var pos_d = pt["position"] + var pos := Vector2(float(pos_d.get("x", 0.0)), float(pos_d.get("y", 0.0))) + var lt := float(pt.get("left_tangent", 0.0)) + var rt := float(pt.get("right_tangent", 0.0)) + var lm := _tangent_mode(pt.get("left_mode", null)) + var rm := _tangent_mode(pt.get("right_mode", null)) + curve.add_point(pos, lt, rt, lm, rm) + "remove_point": + if index_raw == null: + return MCPToolkitError.fail("INVALID_PARAMS", + "action 'remove_point' requires an 'index'") + var index := int(index_raw) + if index < 0 or index >= curve.point_count: + return MCPToolkitError.fail("INVALID_PARAMS", + "index %d out of range (point_count=%d)" % [index, curve.point_count]) + curve.remove_point(index) + "clear": + curve.clear_points() + + return await _save_resource(curve, file_path, "procedural.edit_curve", + {"point_count": curve.point_count}) + + +static func _cmd_edit_noise(parameters: Dictionary) -> Dictionary: + var err = MCPToolkitError.require(parameters, ["file_path"]) + if err != null: + return err + + var file_path := str(parameters.get("file_path", "")) + file_path = Helpers.normalize_editor_path(file_path) + + var guard := FileGuard.resolve_safe(file_path) + if guard["error"] != null: + return MCPToolkitError.fail("PATH_DENIED", str(guard["reason"])) + if file_path.get_extension().to_lower() != "tres": + return MCPToolkitError.fail("INVALID_PATH", + "procedural.edit_noise writes .tres files (got %s)" % file_path) + + # Load or create FastNoiseLite. + var noise: FastNoiseLite + if FileAccess.file_exists(file_path): + var loaded = ResourceLoader.load(file_path, "", ResourceLoader.CACHE_MODE_IGNORE) + if loaded == null or not (loaded is FastNoiseLite): + return MCPToolkitError.fail("INVALID_CLASS", + "Resource at %s is not a FastNoiseLite" % file_path) + noise = loaded as FastNoiseLite + else: + noise = FastNoiseLite.new() + + # Noise type. + var noise_type_raw = parameters.get("noise_type", null) + if noise_type_raw != null: + var nt_str := str(noise_type_raw) + var nt_map := { + "simplex": FastNoiseLite.TYPE_SIMPLEX, + "simplex_smooth": FastNoiseLite.TYPE_SIMPLEX_SMOOTH, + "cellular": FastNoiseLite.TYPE_CELLULAR, + "perlin": FastNoiseLite.TYPE_PERLIN, + "value": FastNoiseLite.TYPE_VALUE, + "value_cubic": FastNoiseLite.TYPE_VALUE_CUBIC, + } + if not nt_map.has(nt_str): + return MCPToolkitError.fail("INVALID_PARAMS", + "noise_type must be one of: %s (got '%s')" % [ + ", ".join(nt_map.keys()), nt_str]) + noise.noise_type = nt_map[nt_str] + + # Simple numeric properties. + if parameters.has("seed"): + noise.seed = int(parameters["seed"]) + if parameters.has("frequency"): + noise.frequency = float(parameters["frequency"]) + if parameters.has("octaves"): + noise.fractal_octaves = int(parameters["octaves"]) + if parameters.has("lacunarity"): + noise.fractal_lacunarity = float(parameters["lacunarity"]) + if parameters.has("gain"): + noise.fractal_gain = float(parameters["gain"]) + + # Fractal type. + var fractal_raw = parameters.get("fractal_type", null) + if fractal_raw != null: + var ft_str := str(fractal_raw) + var ft_map := { + "none": FastNoiseLite.FRACTAL_NONE, + "fbm": FastNoiseLite.FRACTAL_FBM, + "ridged": FastNoiseLite.FRACTAL_RIDGED, + "ping_pong": FastNoiseLite.FRACTAL_PING_PONG, + } + if not ft_map.has(ft_str): + return MCPToolkitError.fail("INVALID_PARAMS", + "fractal_type must be one of: %s (got '%s')" % [ + ", ".join(ft_map.keys()), ft_str]) + noise.fractal_type = ft_map[ft_str] + + # Cellular distance function. + var cdf_raw = parameters.get("cellular_distance_function", null) + if cdf_raw != null: + var cdf_str := str(cdf_raw) + var cdf_map := { + "euclidean": FastNoiseLite.DISTANCE_EUCLIDEAN, + "euclidean_squared": FastNoiseLite.DISTANCE_EUCLIDEAN_SQUARED, + "manhattan": FastNoiseLite.DISTANCE_MANHATTAN, + "hybrid": FastNoiseLite.DISTANCE_HYBRID, + } + if not cdf_map.has(cdf_str): + return MCPToolkitError.fail("INVALID_PARAMS", + "cellular_distance_function must be one of: %s (got '%s')" % [ + ", ".join(cdf_map.keys()), cdf_str]) + noise.cellular_distance_function = cdf_map[cdf_str] + + # Cellular return type. + var crt_raw = parameters.get("cellular_return_type", null) + if crt_raw != null: + var crt_str := str(crt_raw) + var crt_map := { + "cell_value": FastNoiseLite.RETURN_CELL_VALUE, + "distance": FastNoiseLite.RETURN_DISTANCE, + "distance2": FastNoiseLite.RETURN_DISTANCE2, + "distance2_add": FastNoiseLite.RETURN_DISTANCE2_ADD, + "distance2_sub": FastNoiseLite.RETURN_DISTANCE2_SUB, + "distance2_mul": FastNoiseLite.RETURN_DISTANCE2_MUL, + "distance2_div": FastNoiseLite.RETURN_DISTANCE2_DIV, + } + if not crt_map.has(crt_str): + return MCPToolkitError.fail("INVALID_PARAMS", + "cellular_return_type must be one of: %s (got '%s')" % [ + ", ".join(crt_map.keys()), crt_str]) + noise.cellular_return_type = crt_map[crt_str] + + # Domain warp. + if parameters.has("domain_warp_enabled"): + noise.domain_warp_enabled = bool(parameters["domain_warp_enabled"]) + if parameters.has("domain_warp_amplitude"): + noise.domain_warp_amplitude = float(parameters["domain_warp_amplitude"]) + + return await _save_resource(noise, file_path, "procedural.edit_noise", + {"noise_type": noise.noise_type}) + + +# -- Helpers ------------------------------------------------------------------ + + +static func _color(d: Variant) -> Color: + return Coerce.color_from_dict(d) + + +static func _tangent_mode(val) -> int: + if val == null: + return Curve.TANGENT_FREE + var s := str(val) + if s == "linear": + return Curve.TANGENT_LINEAR + return Curve.TANGENT_FREE + + +static func _save_resource(resource: Resource, file_path: String, + cmd_name: String, extra: Dictionary) -> Dictionary: + var dir_result := Helpers.ensure_parent_dir(file_path, cmd_name) + if dir_result.has("error"): + return dir_result + var save_err := ResourceSaver.save(resource, file_path) + if save_err != OK: + return MCPToolkitError.fail("SAVE_FAILED", + "ResourceSaver.save returned %d (path=%s)" % [save_err, file_path]) + ResourceLoader.load(file_path, "", ResourceLoader.CACHE_MODE_REPLACE) + await Helpers.ensure_file_indexed(file_path) + var result := {"file_path": file_path} + result.merge(extra) + return MCPToolkitSuccess.ok(result) diff --git a/addons/godot_mcp_toolkit/commands/procedural_commands.gd.uid b/addons/godot_mcp_toolkit/commands/procedural_commands.gd.uid new file mode 100644 index 0000000..2926da8 --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/procedural_commands.gd.uid @@ -0,0 +1 @@ +uid://4a44sonc2kt0 diff --git a/addons/godot_mcp_toolkit/commands/project_commands.gd b/addons/godot_mcp_toolkit/commands/project_commands.gd new file mode 100644 index 0000000..9ba3e92 --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/project_commands.gd @@ -0,0 +1,261 @@ +@tool +extends RefCounted +## project.* command handlers — get_settings, set_setting, layer names. + +const Modules := preload("res://addons/godot_mcp_toolkit/core/modules.gd") +const Coerce = Modules.Coerce +const Untrusted = Modules.Untrusted + +const SECRET_KEY_REGEX := "(?i)password|token|secret|key" +const _VALID_LAYER_CATEGORIES := ["2d_physics", "2d_render", "3d_physics", "3d_render"] + + +static func register(registry: MCPToolkitCommandRegistry, _server: Node) -> void: + registry.add("project.get_settings", func(parameters: Dictionary) -> Dictionary: + return _cmd_project_get_settings(parameters) + , MCPToolkitCommandOptions.new().mark_read_only().mark_scene_independent()) + registry.add("project.set_setting", func(parameters: Dictionary) -> Dictionary: + return _cmd_project_set_setting(parameters) + , MCPToolkitCommandOptions.new().mark_scene_independent()) + registry.add("autoload.manage", func(parameters: Dictionary) -> Dictionary: + return _cmd_autoload_manage(parameters, _server) + , MCPToolkitCommandOptions.new().mark_scene_independent()) + registry.add("project.get_layer_names", func(parameters: Dictionary) -> Dictionary: + return _cmd_get_layer_names(parameters) + , MCPToolkitCommandOptions.new().mark_read_only().mark_scene_independent()) + registry.add("project.set_layer_names", func(parameters: Dictionary) -> Dictionary: + return _cmd_set_layer_names(parameters) + , MCPToolkitCommandOptions.new().mark_scene_independent()) + + +# -- Commands ----------------------------------------------------------------- + + +static func _cmd_project_get_settings(parameters: Dictionary) -> Dictionary: + var prefix := str(parameters.get("prefix", "")) + + var regex := RegEx.new() + var compile_error := regex.compile(SECRET_KEY_REGEX) + if compile_error != OK: + return MCPToolkitError.fail("INTERNAL", + "secret regex failed to compile (err %d)" % compile_error) + + var settings := {} + var filtered_secrets := 0 + for property in ProjectSettings.get_property_list(): + var property_name := str(property.get("name", "")) + if property_name.is_empty() or not property_name.contains("/"): + continue + if not prefix.is_empty() and not property_name.begins_with(prefix): + continue + if regex.search(property_name) != null: + filtered_secrets += 1 + continue + settings[property_name] = Coerce.serialize_value( + ProjectSettings.get_setting(property_name)) + + return MCPToolkitSuccess.ok({ + "settings": Untrusted.wrap( + "project_settings", "godot", JSON.stringify(settings)), + "count": settings.size(), + "filtered_secret_count": filtered_secrets, + }) + + +static func _cmd_project_set_setting(parameters: Dictionary) -> Dictionary: + var key := str(parameters.get("setting", "")) + if key.is_empty(): + return MCPToolkitError.fail("INVALID_PARAMS", "setting must be a non-empty string") + # The `mcp_toolkit/limits/*` slice has a dedicated writer — meta.set_limits — + # which applies floor clamps. Refusing the whole prefix here keeps that the + # single sanctioned path for toolkit settings (rest go through the dock UI). + if key.begins_with("mcp_toolkit/"): + return MCPToolkitError.fail("INVALID_PATH", + "refusing to write mcp_toolkit/* from project.set_setting (those are the toolkit's own settings — use the dock UI); got key=%s" % key) + if key.begins_with("mcp/"): + return MCPToolkitError.fail("INVALID_PATH", + "refusing to write mcp/* from project.set_setting (those are the toolkit's internal settings); got key=%s" % key) + if key.begins_with("editor/"): + return MCPToolkitError.fail("INVALID_PATH", + "refusing to write editor/* ProjectSettings from project.set_setting (editor-session state, not project config); got key=%s" % key) + # Autoload guard — registration/unregistration must go through autoload_manage + # so the editor's singleton cache refreshes immediately. + if key.begins_with("autoload/"): + var _al_raw = parameters.get("value", null) + var _al_str := str(_al_raw) if _al_raw != null else "" + var _al_name := key.substr("autoload/".length()) + # Registration attempt: value looks like a script path. + if _al_str.begins_with("*res://") or _al_str.begins_with("res://"): + return MCPToolkitError.fail("INVALID_PARAMS", + "Use autoload_manage (action='register', name='%s', script_path='%s') instead of project.set_setting for autoload registration. project.set_setting bypasses the editor's autoload cache, causing unresolved identifier errors until the project is reloaded." % [_al_name, _al_str.lstrip("*")]) + # Unregistration attempt: null, empty, or missing value. + if _al_raw == null or _al_str.is_empty(): + return MCPToolkitError.fail("INVALID_PARAMS", + "Use autoload_manage (action='unregister', name='%s') instead of project.set_setting for autoload removal. project.set_setting bypasses the editor's autoload cache." % _al_name) + if not parameters.has("value"): + return MCPToolkitError.fail("INVALID_PARAMS", "missing value") + var raw_value = parameters.get("value", null) + # Resource-typed values in raw_value are gated through FileGuard + # via Coerce.coerce_value's Resource branch. + var coerced = Coerce.coerce_value(raw_value) + if typeof(coerced) == TYPE_DICTIONARY and (coerced as Dictionary).has("_coerce_error"): + return MCPToolkitError.fail("INVALID_PARAMS", str(coerced["_coerce_error"])) + var was_set_before := ProjectSettings.has_setting(key) + var previous_value = ProjectSettings.get_setting(key) if was_set_before else null + ProjectSettings.set_setting(key, coerced) + var save_error := ProjectSettings.save() + if save_error != OK: + return MCPToolkitError.fail("SAVE_FAILED", + "ProjectSettings.save returned %d (key=%s); change is in-memory but not persisted" % [ + save_error, key]) + var response := { + "key": key, + "value": Coerce.serialize_value(coerced), + "was_set_before": was_set_before, + "previous_value": Coerce.serialize_value(previous_value) if was_set_before else null, + } + if key.begins_with("autoload/"): + response["warning"] = "Modifying autoload/* via project.set_setting — the editor cache won't refresh. Consider using autoload_manage instead." + return MCPToolkitSuccess.ok(response) + + +static func _cmd_autoload_manage(parameters: Dictionary, server: Node = null) -> Dictionary: + var action := str(parameters.get("action", "")) + if action.is_empty(): + return MCPToolkitError.fail("INVALID_PARAMS", "missing action (register|unregister|list)") + + match action: + "register": + var aname := str(parameters.get("name", "")) + var script_path := str(parameters.get("script_path", "")) + if aname.is_empty() or script_path.is_empty(): + return MCPToolkitError.fail("INVALID_PARAMS", + "register requires name and script_path") + if not script_path.begins_with("res://"): + return MCPToolkitError.fail("INVALID_PATH", + "script_path must start with res:// (got %s)" % script_path) + if not FileAccess.file_exists(script_path): + return MCPToolkitError.fail("NOT_FOUND", + "script not found: %s; create it with script_write first" % script_path) + var enabled := bool(parameters.get("enabled", true)) + var value := ("*" if enabled else "") + script_path + # Use EditorPlugin API for immediate editor cache refresh. + var plugin: EditorPlugin = server.get("editor_plugin") if server != null else null + if plugin != null and enabled: + plugin.add_autoload_singleton(aname, script_path) + else: + ProjectSettings.set_setting("autoload/" + aname, value) + var save_error := ProjectSettings.save() + if save_error != OK: + return MCPToolkitError.fail("SAVE_FAILED", + "ProjectSettings.save returned %d" % save_error) + # Emit settings_changed so GDScriptParser refreshes its compile + # cache (add_autoload_singleton does this internally; the direct + # ProjectSettings path does not). + ProjectSettings.emit_signal("settings_changed") + var filesystem := EditorInterface.get_resource_filesystem() + if filesystem != null: + filesystem.update_file(script_path) + return MCPToolkitSuccess.ok({"action": "register", "name": aname, + "script_path": script_path, "enabled": enabled, + "hint": "Autoload registered and editor cache updated. Reference via get_node('/root/%s')." % aname}) + + "unregister": + var aname := str(parameters.get("name", "")) + if aname.is_empty(): + return MCPToolkitError.fail("INVALID_PARAMS", "unregister requires name") + var key := "autoload/" + aname + if not ProjectSettings.has_setting(key): + return MCPToolkitError.fail("NOT_FOUND", + "no autoload named '%s' in project settings" % aname) + # Use EditorPlugin API for immediate editor cache refresh. + var plugin: EditorPlugin = server.get("editor_plugin") if server != null else null + if plugin != null: + plugin.remove_autoload_singleton(aname) + else: + ProjectSettings.clear(key) + var save_error := ProjectSettings.save() + if save_error != OK: + return MCPToolkitError.fail("SAVE_FAILED", + "ProjectSettings.save returned %d" % save_error) + ProjectSettings.emit_signal("settings_changed") + return MCPToolkitSuccess.ok({"action": "unregister", "name": aname}) + + "list": + var autoloads: Array = [] + for property in ProjectSettings.get_property_list(): + var property_name := str(property.get("name", "")) + if not property_name.begins_with("autoload/"): + continue + var aname := property_name.substr("autoload/".length()) + var raw_value := str(ProjectSettings.get_setting(property_name)) + var enabled := raw_value.begins_with("*") + var path := raw_value.lstrip("*") + autoloads.append({"name": aname, "script_path": path, "enabled": enabled}) + return MCPToolkitSuccess.ok({"action": "list", "autoloads": autoloads, + "count": autoloads.size()}) + + _: + return MCPToolkitError.fail("INVALID_PARAMS", + "unknown action '%s'; must be register|unregister|list" % action) + + +## Validate the layer category param. Returns null when valid, an +## MCPToolkitError dict otherwise. +static func _validate_layer_category(category: String) -> Variant: + if category.is_empty(): + return MCPToolkitError.fail("INVALID_PARAMS", "missing category") + if not category in _VALID_LAYER_CATEGORIES: + return MCPToolkitError.fail("INVALID_PARAMS", + "invalid category '%s'; must be one of: %s" % [ + category, ", ".join(_VALID_LAYER_CATEGORIES)]) + return null + + +static func _cmd_get_layer_names(parameters: Dictionary) -> Dictionary: + var category := str(parameters.get("category", "")) + var cat_err = _validate_layer_category(category) + if cat_err != null: + return cat_err + + var layers := {} + for n in range(1, 33): + var key := "layer_names/%s/layer_%d" % [category, n] + if ProjectSettings.has_setting(key): + var name: String = str(ProjectSettings.get_setting(key)) + if not name.is_empty(): + layers[n] = name + return MCPToolkitSuccess.ok({"category": category, "layers": layers}) + + +static func _cmd_set_layer_names(parameters: Dictionary) -> Dictionary: + var category := str(parameters.get("category", "")) + var cat_err = _validate_layer_category(category) + if cat_err != null: + return cat_err + + var raw_layers = parameters.get("layers", null) + if raw_layers == null or typeof(raw_layers) != TYPE_DICTIONARY: + return MCPToolkitError.fail("INVALID_PARAMS", + "layers must be a dictionary mapping layer numbers (1-32) to names") + + var layers_dict: Dictionary = raw_layers + for raw_key in layers_dict: + var n: int = int(raw_key) + if n < 1 or n > 32: + return MCPToolkitError.fail("INVALID_PARAMS", + "layer number %s out of range; must be 1-32" % str(raw_key)) + + var count := 0 + for raw_key in layers_dict: + var n: int = int(raw_key) + var layer_name: String = str(layers_dict[raw_key]) + var key := "layer_names/%s/layer_%d" % [category, n] + ProjectSettings.set_setting(key, layer_name) + count += 1 + + var save_error := ProjectSettings.save() + if save_error != OK: + return MCPToolkitError.fail("SAVE_FAILED", + "ProjectSettings.save returned %d; changes are in-memory but not persisted" % save_error) + return MCPToolkitSuccess.ok({"layers_set": count}) diff --git a/addons/godot_mcp_toolkit/commands/project_commands.gd.uid b/addons/godot_mcp_toolkit/commands/project_commands.gd.uid new file mode 100644 index 0000000..15e01ab --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/project_commands.gd.uid @@ -0,0 +1 @@ +uid://ym3m82oivdmb diff --git a/addons/godot_mcp_toolkit/commands/resource_commands.gd b/addons/godot_mcp_toolkit/commands/resource_commands.gd new file mode 100644 index 0000000..7de54db --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/resource_commands.gd @@ -0,0 +1,229 @@ +@tool +extends RefCounted +## resource.* command handlers — load, write (create/update upsert), delete for .tres/.res files. + +const Modules := preload("res://addons/godot_mcp_toolkit/core/modules.gd") +const Coerce = Modules.Coerce +const FileGuard = Modules.FileGuard +const Untrusted = Modules.Untrusted +const Helpers = Modules.CommandHelpers + +const RESOURCE_SKIP_PROPERTIES: Array[String] = [ + "image", "mesh_arrays", "surface_arrays", "_data", +] + + +static func register(registry: MCPToolkitCommandRegistry, _server: Node) -> void: + registry.add("resource.load", func(parameters: Dictionary) -> Dictionary: + return _cmd_resource_load(parameters) + , MCPToolkitCommandOptions.new().mark_read_only().mark_scene_independent()) + registry.add("resource.write", func(parameters: Dictionary) -> Dictionary: + return await _cmd_resource_write(parameters) + , MCPToolkitCommandOptions.new().mark_scene_independent()) + registry.add("resource.delete", func(parameters: Dictionary) -> Dictionary: + return await _cmd_resource_delete(parameters) + , MCPToolkitCommandOptions.new().mark_scene_independent()) + + +# -- Helpers ------------------------------------------------------------------ + + +static func _property_names_of(object: Object) -> Dictionary: + var names := {} + for property in object.get_property_list(): + var property_name := str(property.get("name", "")) + if not property_name.is_empty(): + names[property_name] = true + return names + + +static func _apply_resource_properties( + resource: Resource, properties: Dictionary, resource_class: String, +) -> Array[String]: + var warnings: Array[String] = [] + var valid := _property_names_of(resource) + for key in properties.keys(): + var key_string := str(key) + var raw_value = properties[key] + var missing := Coerce.check_resource_paths(raw_value) + if missing != "": + warnings.append( + "property '%s': resource not found at %s; value left unchanged" % [key_string, missing]) + continue + var coerced = Coerce.coerce_value(raw_value) + if typeof(coerced) == TYPE_DICTIONARY and (coerced as Dictionary).has("_coerce_error"): + warnings.append( + "property '%s': %s; value left unchanged" % [key_string, str(coerced["_coerce_error"])]) + continue + # Compound paths (e.g. "sources/0", "tiles/0:0/0") route through + # Object._set() which many built-in types (TileSet, AnimationLibrary) + # use for sub-resource slots not exposed in get_property_list(). + if "/" in key_string: + var before = resource.get(key_string) + resource.set(key_string, coerced) + var after = resource.get(key_string) + if typeof(after) == typeof(before) and after == before: + if resource_class == "ShaderMaterial" and key_string.begins_with("shader_parameter/"): + # shader_parameter/ is an inspector-only projection; Object.set() + # on it does not reliably route to set_shader_parameter, and no-ops when + # the uniform isn't declared or no shader is assigned. + warnings.append( + "property '%s' on ShaderMaterial: set() had no effect. Set shader uniforms via set_shader_parameter(name, value); ensure the ShaderMaterial has a shader assigned and the uniform is declared. The shader_parameter/ path is inspector-only." % key_string) + else: + warnings.append( + "property '%s' on %s: set() had no effect (may need a different API)" % [key_string, resource_class]) + elif not valid.has(key_string): + warnings.append( + "property '%s' unknown on %s; value ignored" % [key_string, resource_class]) + else: + resource.set(key_string, coerced) + return warnings + + +# -- Commands ----------------------------------------------------------------- + + +static func _cmd_resource_load(parameters: Dictionary) -> Dictionary: + var err = MCPToolkitError.require(parameters, ["file_path"]) + if err != null: + return err + var file_path := str(parameters.get("file_path", "")) + var guard := FileGuard.resolve_safe(file_path) + if guard["error"] != null: + return MCPToolkitError.fail("PATH_DENIED", str(guard["reason"])) + if not ResourceLoader.exists(file_path): + return MCPToolkitError.fail("NOT_FOUND", "resource not found: %s" % file_path, MCPToolkitError.HINT_FILE_PATH) + var resource := ResourceLoader.load(file_path) + if resource == null: + return MCPToolkitError.fail("LOAD_FAILED", + "ResourceLoader returned null for %s" % file_path) + var resource_class := resource.get_class() + var properties := {} + for property in resource.get_property_list(): + var usage: int = int(property.get("usage", 0)) + if not (usage & PROPERTY_USAGE_EDITOR): + continue + var property_name := str(property.get("name", "")) + if property_name.is_empty() or property_name.begins_with("_"): + continue + if property_name in RESOURCE_SKIP_PROPERTIES: + continue + properties[property_name] = Coerce.serialize_value(resource.get(property_name)) + var metadata := {} + if resource is Texture2D: + metadata["width"] = resource.get_width() + metadata["height"] = resource.get_height() + return MCPToolkitSuccess.ok({ + "class": resource_class, + "path": file_path, + "properties": Untrusted.wrap( + "resource", file_path, JSON.stringify(properties)), + "metadata": metadata, + }) + + +static func _cmd_resource_write(parameters: Dictionary) -> Dictionary: + var err = MCPToolkitError.require(parameters, ["file_path"]) + if err != null: + return err + var file_path := str(parameters.get("file_path", "")) + var guard := FileGuard.resolve_safe(file_path) + if guard["error"] != null: + return MCPToolkitError.fail("PATH_DENIED", str(guard["reason"])) + var extension := file_path.get_extension().to_lower() + if not (extension in ["tres", "res"]): + return MCPToolkitError.fail("INVALID_PATH", + "resource.write only writes .tres/.res files (got %s); use script.write for .gd/.cs" % file_path) + var properties: Dictionary = parameters.get("properties", {}) \ + if typeof(parameters.get("properties", {})) == TYPE_DICTIONARY else {} + if FileAccess.file_exists(file_path): + var resource := ResourceLoader.load(file_path) + if resource == null: + return MCPToolkitError.fail("NOT_A_RESOURCE", + "file at %s is not a readable Resource" % file_path) + var resource_class := resource.get_class() + var warnings := _apply_resource_properties(resource, properties, resource_class) + # type only selects the class when creating; an existing file keeps its own + # class. Disclose a passed type so it doesn't read as a silent retype. + if parameters.has("type"): + warnings.append("ignored type '%s' — resource already exists; type only applies when creating" % str(parameters.get("type", ""))) + var save_error := ResourceSaver.save(resource, file_path) + if save_error != OK: + return MCPToolkitError.fail("SAVE_FAILED", + "ResourceSaver.save returned %d (path=%s)" % [save_error, file_path]) + # Refresh cache so subsequent ResourceRef loads get the updated version + ResourceLoader.load(file_path, "", ResourceLoader.CACHE_MODE_REPLACE) + var update_index := await Helpers.ensure_file_indexed(file_path) + return MCPToolkitSuccess.ok({ + "path": file_path, + "resource_class": resource_class, + "warnings": warnings, + "indexed": update_index["indexed"], + }) + var resource_class := str(parameters.get("type", "")) + if resource_class.is_empty(): + return MCPToolkitError.fail("NOT_FOUND", + "resource not found at %s; provide 'type' to create it" % file_path, MCPToolkitError.HINT_FILE_PATH) + var dir_result := Helpers.ensure_parent_dir(file_path, "resource.write") + if dir_result.has("error"): + return dir_result + var dirs_created: bool = dir_result["dirs_created"] + var rk := Helpers.resolve_class_kind(resource_class) + var resolved_kind: String = rk["kind"] + var global_entry: Dictionary = rk["entry"] + if resolved_kind.is_empty(): + return MCPToolkitError.fail("INVALID_CLASS", + "unknown class %s; check ClassDB or ProjectSettings global class list" % resource_class, MCPToolkitError.HINT_CLASS_NAME) + if not Helpers.class_descends_from(resource_class, "Resource"): + return MCPToolkitError.fail("NOT_A_RESOURCE", + "%s is not a Resource subclass (base chain: %s)" % [ + resource_class, Helpers.class_base_chain(resource_class)]) + var resource: Resource = null + if resolved_kind == "native": + resource = ClassDB.instantiate(resource_class) + else: + var script_path := str(global_entry.get("path", "")) + var script = load(script_path) + if script == null: + return MCPToolkitError.fail("INVALID_CLASS", + "could not load script for %s at %s" % [resource_class, script_path]) + resource = script.new() + if resource == null: + return MCPToolkitError.fail("INVALID_CLASS", + "instantiation returned null for %s" % resource_class) + var warnings := _apply_resource_properties(resource, properties, resource_class) + var save_error := ResourceSaver.save(resource, file_path) + if save_error != OK: + return MCPToolkitError.fail("SAVE_FAILED", + "ResourceSaver.save returned %d (path=%s)" % [save_error, file_path]) + # Refresh cache so subsequent ResourceRef loads get the new resource + ResourceLoader.load(file_path, "", ResourceLoader.CACHE_MODE_REPLACE) + var create_index := await Helpers.ensure_file_indexed(file_path) + var create_result := { + "status": "created", + "path": file_path, + "resource_class": resource_class, + "warnings": warnings, + "indexed": create_index["indexed"], + } + if dirs_created: + create_result["dirs_created"] = true + return MCPToolkitSuccess.ok(create_result) + + +static func _cmd_resource_delete(parameters: Dictionary) -> Dictionary: + var err = MCPToolkitError.require(parameters, ["file_path"]) + if err != null: + return err + var file_path := str(parameters.get("file_path", "")) + var guard := FileGuard.resolve_safe(file_path) + if guard["error"] != null: + return MCPToolkitError.fail("PATH_DENIED", str(guard["reason"])) + var extension := file_path.get_extension().to_lower() + if not (extension in ["tres", "res"]): + return MCPToolkitError.fail("INVALID_PATH", + "resource.delete only removes .tres or .res files (got %s); use scene.delete for .tscn, script.delete for .gd/.cs/.gdshader/.gdshaderinc, or a different tool for other file types" % file_path) + if not FileAccess.file_exists(file_path): + return MCPToolkitError.fail("NOT_FOUND", "no file at %s" % file_path, MCPToolkitError.HINT_FILE_PATH) + var delete_result: Dictionary = await Helpers.delete_res_file_and_deindex(file_path) + return delete_result diff --git a/addons/godot_mcp_toolkit/commands/resource_commands.gd.uid b/addons/godot_mcp_toolkit/commands/resource_commands.gd.uid new file mode 100644 index 0000000..e7ad49c --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/resource_commands.gd.uid @@ -0,0 +1 @@ +uid://dwpdf51ej5nqn diff --git a/addons/godot_mcp_toolkit/commands/save_commands.gd b/addons/godot_mcp_toolkit/commands/save_commands.gd new file mode 100644 index 0000000..4ec9412 --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/save_commands.gd @@ -0,0 +1,176 @@ +@tool +extends RefCounted +## save.* command handlers — user:// file operations. + +const Modules := preload("res://addons/godot_mcp_toolkit/core/modules.gd") +const FileGuard = Modules.FileGuard +const Untrusted = Modules.Untrusted +const Scrubber = Modules.Scrubber + + +static func register(registry: MCPToolkitCommandRegistry, _server: Node) -> void: + registry.add("save.read", func(parameters: Dictionary) -> Dictionary: + return _cmd_save_read(parameters) + , MCPToolkitCommandOptions.new().mark_read_only().mark_scene_independent()) + registry.add("save.write", func(parameters: Dictionary) -> Dictionary: + return _cmd_save_write(parameters) + , MCPToolkitCommandOptions.new().mark_scene_independent()) + registry.add("save.delete", func(parameters: Dictionary) -> Dictionary: + return _cmd_save_delete(parameters) + , MCPToolkitCommandOptions.new().mark_scene_independent()) + registry.add("save.list", func(parameters: Dictionary) -> Dictionary: + return _cmd_save_list(parameters) + , MCPToolkitCommandOptions.new().mark_read_only().mark_scene_independent()) + + +# -- Commands ----------------------------------------------------------------- + + +static func _cmd_save_write(parameters: Dictionary) -> Dictionary: + var path := str(parameters.get("path", "")) + var content := str(parameters.get("content", "")) + if path.is_empty(): + return MCPToolkitError.fail("INVALID_PARAMS", "missing path") + var guard := FileGuard.resolve_safe_user(path) + if not guard["ok"]: + return MCPToolkitError.fail(str(guard["error_code"]), str(guard["error_message"])) + var abs_path: String = guard["absolute_path"] + DirAccess.make_dir_recursive_absolute(abs_path.get_base_dir()) + var f := FileAccess.open(abs_path, FileAccess.WRITE) + if f == null: + return MCPToolkitError.fail("SAVE_WRITE_FAILED", + "FileAccess.open for write failed (error=%d, path=%s)" % [FileAccess.get_open_error(), path]) + f.store_string(content) + f.close() + return MCPToolkitSuccess.ok({"path": path, "bytes_written": content.length()}) + + +static func _cmd_save_read(parameters: Dictionary) -> Dictionary: + var path := str(parameters.get("path", "")) + if path.is_empty(): + return MCPToolkitError.fail("INVALID_PARAMS", "missing path") + # Cap is configurable (mcp_toolkit/limits/save_read_cap_kb, default 256, min + # 64) — mirrors script_read_cap_kb. Default 256 KB ⇒ 262144 ⇒ the former + # hardcoded ceiling, so the default behaviour is unchanged. + var cap_kb: int = ProjectSettings.get_setting("mcp_toolkit/limits/save_read_cap_kb", 256) + var cap_bytes := maxi(64, cap_kb) * 1024 + var max_bytes := int(parameters.get("max_bytes", 65536)) + if max_bytes <= 0 or max_bytes > cap_bytes: + return MCPToolkitError.fail("INVALID_PARAMS", + "max_bytes must be 1..%d (got %d); raise mcp_toolkit/limits/save_read_cap_kb to read a larger window" + % [cap_bytes, max_bytes]) + # Byte offset for pagination (default 0) — parity with script.read's line + # range. Lets a large file be read in successive ≤max_bytes windows under the + # WebSocket frame ceiling, which is the only way the engine ships big files. + var offset := int(parameters.get("offset", 0)) + if offset < 0: + return MCPToolkitError.fail("INVALID_PARAMS", + "offset must be >= 0 (got %d)" % offset) + var guard := FileGuard.resolve_safe_user(path) + if not guard["ok"]: + return MCPToolkitError.fail(str(guard["error_code"]), str(guard["error_message"])) + var abs_path: String = guard["absolute_path"] + var f := FileAccess.open(abs_path, FileAccess.READ) + if f == null: + return MCPToolkitError.fail("SAVE_READ_FAILED", + "FileAccess.open for read failed (error=%d, path=%s)" % [FileAccess.get_open_error(), path]) + var total_bytes := int(f.get_length()) + # Bytes remaining after the offset. An offset at or past EOF yields 0 bytes + # (not an error) so a paging caller can detect completion by next_offset. + var remaining := maxi(0, total_bytes - offset) + var bytes_to_read := mini(remaining, max_bytes) + # Frame-size guard: the engine drops any WebSocket frame larger than the + # per-peer outbound buffer wholesale (send_text's result is unchecked → silent + # drop). Reject BEFORE reading so an oversized window never reaches the + # transport. Sizing basis is the base64 worst case (1.33×), since the binary + # fallback path inflates the payload by ~33%. At defaults this never fires + # (256 KB × 1.33 ≈ 340 KB < 1 MB); it guards the footgun of raising + # save_read_cap_kb above ws_buffer_kb. + var ws_buffer_kb: int = ProjectSettings.get_setting("mcp_toolkit/limits/ws_buffer_kb", 1024) + var projected_bytes := int(ceil(bytes_to_read * 1.33)) + if projected_bytes > ws_buffer_kb * 1024: + f.close() + var size_err := MCPToolkitError.fail("FILE_TOO_LARGE", + "projected response (~%d KB base64) exceeds the %d KB WebSocket buffer" + % [projected_bytes / 1024, ws_buffer_kb]) + size_err["total_bytes"] = total_bytes + size_err["hint"] = "narrow the window: use offset + a smaller max_bytes" + return size_err + if offset > 0: + f.seek(offset) + var buffer := f.get_buffer(bytes_to_read) + f.close() + var next_offset := offset + buffer.size() + # has_more means there is more file beyond what this window returned. + var has_more := next_offset < total_bytes + # Uniform pagination contract: on a read with more remaining, hand the LLM a prose + # loop instruction — re-call with offset = next_offset until has_more is false. + # Present ONLY when has_more; the field is absent on a complete read. + var page_hint := "" + if has_more: + page_hint = "more bytes remain — re-call save.read with offset = next_offset (%d) until has_more is false" % next_offset + # Binary-safe: try UTF-8 decode; fall back to base64. + var text := buffer.get_string_from_utf8() + if text.is_empty() and buffer.size() > 0: + return MCPToolkitSuccess.ok(Modules.Pagination.byte_page( + { + "path": path, + "content_base64": Marshalls.raw_to_base64(buffer), + "encoding": "base64", + "offset": offset, + }, + offset, buffer.size(), total_bytes, page_hint)) + var scrubbed := Scrubber.scrub(text, "save.read") + var wrapped := Untrusted.wrap("user-file", path, scrubbed["text"]) + return MCPToolkitSuccess.ok(Modules.Pagination.byte_page( + { + "path": path, + "content": wrapped, + "offset": offset, + }, + offset, buffer.size(), total_bytes, page_hint)) + + +static func _cmd_save_delete(parameters: Dictionary) -> Dictionary: + var path := str(parameters.get("path", "")) + if path.is_empty(): + return MCPToolkitError.fail("INVALID_PARAMS", "missing path") + var guard := FileGuard.resolve_safe_user(path) + if not guard["ok"]: + return MCPToolkitError.fail(str(guard["error_code"]), str(guard["error_message"])) + var abs_path: String = guard["absolute_path"] + if not FileAccess.file_exists(abs_path): + return MCPToolkitError.fail("NOT_FOUND", "no file at %s" % path) + var err := DirAccess.remove_absolute(abs_path) + if err != OK: + return MCPToolkitError.fail("SAVE_DELETE_FAILED", + "DirAccess.remove_absolute returned %d (path=%s)" % [err, path]) + return MCPToolkitSuccess.ok({"path": path}) + + +static func _cmd_save_list(parameters: Dictionary) -> Dictionary: + var path := str(parameters.get("path", "")) + if path.is_empty(): + return MCPToolkitError.fail("INVALID_PARAMS", "missing path") + if not path.ends_with("/"): + return MCPToolkitError.fail("INVALID_PATH", + "save.list requires a directory path ending with / (got %s); use save.read for a single file" % path) + var guard := FileGuard.resolve_safe_user(path) + if not guard["ok"]: + return MCPToolkitError.fail(str(guard["error_code"]), str(guard["error_message"])) + var abs_path: String = guard["absolute_path"] + if not DirAccess.dir_exists_absolute(abs_path): + return MCPToolkitError.fail("NOT_FOUND", "no directory at %s" % path) + var d := DirAccess.open(abs_path) + if d == null: + return MCPToolkitError.fail("SAVE_READ_FAILED", + "DirAccess.open failed (path=%s)" % path) + var files := Array(d.get_files()) + var dirs := Array(d.get_directories()) + return MCPToolkitSuccess.ok({ + "path": path, + "files": files, + "directories": dirs, + "file_count": files.size(), + "directory_count": dirs.size(), + }) diff --git a/addons/godot_mcp_toolkit/commands/save_commands.gd.uid b/addons/godot_mcp_toolkit/commands/save_commands.gd.uid new file mode 100644 index 0000000..03063ea --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/save_commands.gd.uid @@ -0,0 +1 @@ +uid://5ygfiss08l3m diff --git a/addons/godot_mcp_toolkit/commands/scene_commands.gd b/addons/godot_mcp_toolkit/commands/scene_commands.gd new file mode 100644 index 0000000..d51500e --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/scene_commands.gd @@ -0,0 +1,1043 @@ +@tool +extends RefCounted +## scene.* command handlers — tree read, scene create/open/close/delete, +## node creation, instantiation. + +const Modules := preload("res://addons/godot_mcp_toolkit/core/modules.gd") +const Coerce = Modules.Coerce +const FileGuard = Modules.FileGuard +const Untrusted = Modules.Untrusted +const Helpers = Modules.CommandHelpers + +const _TAB_CLOSE_NOISE_HINT := "Closing a non-active scene tab may produce a _set_main_scene_state error in the editor console. This is benign Godot engine noise — safe to ignore." + +# Max scene.query page size. Caps a single page so it can't exceed the transport +# frame budget; a larger requested limit is clamped and disclosed in-band. +const _QUERY_MAX_LIMIT := 200 + + +static func register(registry: MCPToolkitCommandRegistry, server: Node) -> void: + registry.add("scene.get_tree", func(parameters: Dictionary) -> Dictionary: + return _cmd_scene_get_tree(parameters) + , MCPToolkitCommandOptions.new().mark_read_only()) + registry.add("scene.create", func(parameters: Dictionary) -> Dictionary: + return await _cmd_scene_create(parameters) + , MCPToolkitCommandOptions.new().mark_scene_independent()) + registry.add("scene.open", func(parameters: Dictionary) -> Dictionary: + return await _cmd_scene_open(parameters) + , MCPToolkitCommandOptions.new().mark_scene_independent()) + registry.add("scene.close", func(parameters: Dictionary) -> Dictionary: + return await _cmd_scene_close(parameters) + , MCPToolkitCommandOptions.new().mark_scene_independent().with_min_godot_version("4.5")) + registry.add("scene.delete", func(parameters: Dictionary) -> Dictionary: + return await _cmd_scene_delete(parameters) + , MCPToolkitCommandOptions.new().mark_scene_independent()) + registry.add("scene.create_node", func(parameters: Dictionary) -> Dictionary: + return _cmd_scene_create_node(parameters) + , MCPToolkitCommandOptions.new()) + registry.add("scene.delete_node", func(parameters: Dictionary) -> Dictionary: + return _cmd_scene_delete_node(parameters) + , MCPToolkitCommandOptions.new()) + registry.add("scene.instantiate", func(parameters: Dictionary) -> Dictionary: + return _cmd_scene_instantiate(server, parameters) + , MCPToolkitCommandOptions.new()) + registry.add("scene.diff", func(parameters: Dictionary) -> Dictionary: + return _cmd_scene_diff(server, parameters) + , MCPToolkitCommandOptions.new().mark_read_only()) + registry.add("scene.create_inherited", func(parameters: Dictionary) -> Dictionary: + return await _cmd_create_inherited(parameters) + , MCPToolkitCommandOptions.new().mark_scene_independent()) + registry.add("scene.query", func(p: Dictionary) -> Dictionary: + return _cmd_scene_query(p) + , MCPToolkitCommandOptions.new().mark_read_only()) + + +# -- Helpers ------------------------------------------------------------------ + + +static func _get_edited_root() -> Node: + return Helpers.get_edited_root() + + +## Returns the subset of [param candidate_keys] that [param parameters] actually +## carries, preserving the given order. +## +## An idempotent-return branch parses caller args it will not apply when the node +## already exists; this names only the args the caller truly passed, so the +## disclosure stays actionable and never fires on args nobody sent. +static func _passed_keys(parameters: Dictionary, candidate_keys: Array[String]) -> Array[String]: + var passed: Array[String] = [] + for key in candidate_keys: + if parameters.has(key): + passed.append(key) + return passed + + +static func _path_in_scene(scene_root: Node, node: Node) -> String: + return str(scene_root.get_path_to(node)) + + +static func _walk_tree( + node: Node, scene_root: Node, depth: int, include_properties: bool, +) -> Dictionary: + var result := { + "name": String(node.name), + "class": node.get_class(), + "path": _path_in_scene(scene_root, node), + } + if include_properties: + var props := {} + for property in node.get_property_list(): + var usage: int = int(property.get("usage", 0)) + if not (usage & PROPERTY_USAGE_EDITOR): + continue + var property_name := str(property.get("name", "")) + if property_name.is_empty() or property_name.begins_with("_"): + continue + props[property_name] = Coerce.serialize_value(node.get(property_name)) + result["properties"] = props + if depth != 0: + var children: Array = [] + for child in node.get_children(): + children.append(_walk_tree( + child, scene_root, + depth - 1 if depth > 0 else -1, + include_properties)) + result["children"] = children + else: + result["children"] = [] + return result + + +# -- Commands ----------------------------------------------------------------- + + +static func _cmd_scene_get_tree(parameters: Dictionary) -> Dictionary: + var root := _get_edited_root() + if root == null: + return MCPToolkitError.fail("NO_SCENE", "no edited scene") + var depth_raw = parameters.get("max_depth", 2) + var depth: int = int(depth_raw) \ + if (typeof(depth_raw) == TYPE_INT or typeof(depth_raw) == TYPE_FLOAT) else 2 + var include_properties: bool = bool(parameters.get("include_properties", false)) + var tree := _walk_tree(root, root, depth, include_properties) + return MCPToolkitSuccess.ok({"tree": Untrusted.wrap( + "scene_tree", str(root.scene_file_path), JSON.stringify(tree))}) + + +static func _cmd_scene_create(parameters: Dictionary) -> Dictionary: + var err = MCPToolkitError.require(parameters, ["file_path"]) + if err != null: + return err + var file_path := str(parameters.get("file_path", "")) + var root_type := str(parameters.get("root_type", "Node")) + var if_exists := str(parameters.get("if_exists", "return")) + # Optional root_name; empty falls back to the filename stem (preserves the prior default). + var root_name := str(parameters.get("root_name", "")) + if root_name.is_empty(): + root_name = file_path.get_file().get_basename() + var guard := FileGuard.resolve_safe(file_path) + if guard["error"] != null: + return MCPToolkitError.fail("PATH_DENIED", str(guard["reason"])) + if file_path.get_extension().to_lower() != "tscn": + return MCPToolkitError.fail("INVALID_PATH", + "path must end with .tscn (got %s; use script.write for .gd/.cs files)" % file_path) + var dir_result := Helpers.ensure_parent_dir(file_path, "scene.create") + if dir_result.has("error"): + return dir_result + var dirs_created: bool = dir_result["dirs_created"] + + var rk := Helpers.resolve_class_kind(root_type) + var resolved_kind: String = rk["kind"] + var global_entry: Dictionary = rk["entry"] + if resolved_kind.is_empty(): + return MCPToolkitError.fail("INVALID_CLASS", + "unknown class %s; checked ClassDB (engine classes) and ProjectSettings.get_global_class_list() (GDScript class_name + C# [GlobalClass])" % root_type, MCPToolkitError.HINT_CLASS_NAME) + if not Helpers.class_descends_from(root_type, "Node"): + return MCPToolkitError.fail("INVALID_CLASS", + "%s is not a Node subclass (resolved base chain: %s); scene roots must descend from Node" % [root_type, Helpers.class_base_chain(root_type)]) + var collision := Helpers.resolve_create_collision(file_path, if_exists) + if not collision["valid"]: + return MCPToolkitError.fail("INVALID_PARAMS", + "if_exists must be one of 'return'|'fail'|'replace' (got %s); default is 'return'" % if_exists) + + var was_replace := false + var previous_root_type := "" + if collision["existed"]: + match collision["action"]: + "return": + return MCPToolkitSuccess.ok({"status": "returned", "path": file_path, + "root_name": root_name, "root_path": "."}) + "fail": + return MCPToolkitError.fail("ALREADY_EXISTS", + "file exists at %s; set if_exists:'replace' to overwrite" % file_path) + "replace": + was_replace = true + var previous_packed = ResourceLoader.load(file_path) + if previous_packed == null or not (previous_packed is PackedScene): + previous_root_type = "" + else: + var state := (previous_packed as PackedScene).get_state() + if state == null or state.get_node_count() == 0: + previous_root_type = "" + else: + previous_root_type = str(state.get_node_type(0)) + push_warning("[MCPTools] scene.create replacing %s (was root=%s, now root=%s)" % [ + file_path, previous_root_type, root_type]) + + var root: Node = null + if resolved_kind == "native": + root = ClassDB.instantiate(root_type) + else: + var script_path := str(global_entry.get("path", "")) + var script = load(script_path) + if script == null: + return MCPToolkitError.fail("INVALID_CLASS", + "could not load script for %s at %s" % [root_type, script_path]) + root = script.new() + if root == null: + return MCPToolkitError.fail("INVALID_CLASS", + "instantiation returned null for %s" % root_type) + root.name = root_name + var packed := PackedScene.new() + var pack_error := packed.pack(root) + if pack_error != OK: + root.queue_free() + return MCPToolkitError.fail("PACK_FAILED", + "PackedScene.pack returned %d (class=%s, path=%s)" % [pack_error, root_type, file_path]) + var save_error := ResourceSaver.save(packed, file_path) + root.queue_free() + if save_error != OK: + return MCPToolkitError.fail("SAVE_FAILED", + "ResourceSaver.save returned %d (path=%s)" % [save_error, file_path]) + + var scene_index := await Helpers.ensure_file_indexed(file_path) + var response := MCPToolkitSuccess.ok({"path": file_path, "root_type": root_type, + "root_name": root_name, "root_path": ".", + "indexed": scene_index["indexed"], + "hint": "Scene saved. Open it for editing with scene_open."}) + if dirs_created: + response["dirs_created"] = true + if was_replace: + response["status"] = "replaced" + response["previous_root_type"] = previous_root_type + # P-003: If the replaced scene is currently open in the editor, reload + # it from disk so the in-memory tree matches the fresh file. + var open_scenes := EditorInterface.get_open_scenes() + if file_path in open_scenes: + if await Helpers.open_scene_deferred(file_path): + response["reloaded"] = true + response["hint"] = "Scene replaced and reloaded in editor." + else: + response["reloaded"] = false + response["hint"] = "Scene replaced; reload skipped (filesystem scanning) — reopen with scene_open." + else: + response["status"] = "created" + return response + + +static func _cmd_scene_open(parameters: Dictionary) -> Dictionary: + var err = MCPToolkitError.require(parameters, ["file_path"]) + if err != null: + return err + var file_path := str(parameters.get("file_path", "")) + var guard := FileGuard.resolve_safe(file_path) + if guard["error"] != null: + return MCPToolkitError.fail("PATH_DENIED", str(guard["reason"])) + if not FileAccess.file_exists(file_path): + return MCPToolkitError.fail("NOT_FOUND", "scene not found: %s" % file_path, MCPToolkitError.HINT_FILE_PATH) + if not await Helpers.open_scene_deferred(file_path): + return MCPToolkitError.fail("TIMEOUT", + "could not open %s — EditorFileSystem still scanning; retry shortly" % file_path) + return MCPToolkitSuccess.ok({"path": file_path}) + + +static func _cmd_scene_close(parameters: Dictionary) -> Dictionary: + var file_path := str(parameters.get("file_path", "")) + if file_path.is_empty(): + return MCPToolkitError.fail("INVALID_PARAMS", "path is required") + var guard := FileGuard.resolve_safe(file_path) + if guard["error"] != null: + return MCPToolkitError.fail("PATH_DENIED", str(guard["reason"])) + var result := await Helpers.close_scene_tab_safe(file_path) + if result.get("closed", false): + var response := MCPToolkitSuccess.ok({"path": file_path}) + if result.get("switched", false): + response["hint"] = _TAB_CLOSE_NOISE_HINT + # Disclose discarded unsaved edits where the editor can report them (4.7+); + # the helper omits the key below 4.7, so absence here means "not detectable". + if result.has("unsaved_changes_discarded"): + response["unsaved_changes_discarded"] = result["unsaved_changes_discarded"] + return response + var reason := str(result.get("reason", "")) + if reason == "not_open": + return MCPToolkitError.fail("NOT_FOUND", + "scene is not open in any editor tab: %s" % file_path, MCPToolkitError.HINT_FILE_PATH) + if reason == "no_api": + return MCPToolkitError.fail("UNSUPPORTED", + "scene.close requires Godot 4.5+ (connected: %s)" % Modules.VersionUtils.get_engine_version_pair()) + return MCPToolkitError.fail("INTERNAL", "unexpected close_scene_tab_safe reason: %s" % reason) + + +static func _cmd_scene_delete(parameters: Dictionary) -> Dictionary: + var err = MCPToolkitError.require(parameters, ["file_path"]) + if err != null: + return err + var file_path := str(parameters.get("file_path", "")) + var guard := FileGuard.resolve_safe(file_path) + if guard["error"] != null: + return MCPToolkitError.fail("PATH_DENIED", str(guard["reason"])) + if file_path.get_extension().to_lower() != "tscn": + return MCPToolkitError.fail("INVALID_PATH", + "scene.delete only removes .tscn files (got %s); use a different tool for other file types" % file_path) + if not FileAccess.file_exists(file_path): + return MCPToolkitError.fail("NOT_FOUND", "no file at %s" % file_path, MCPToolkitError.HINT_FILE_PATH) + + # Attempt to close the editor tab before deleting the file. + var tab_result := await Helpers.close_scene_tab_safe(file_path) + var tab_closed := tab_result.get("closed", false) + var warnings: Array[String] = [] + + if not tab_closed: + var reason := str(tab_result.get("reason", "")) + if reason == "no_api": + # 4.2–4.4: no close API. Block active-scene deletion (Ctrl+S + # would silently recreate the file). Non-active tabs proceed + # with a phantom warning. + var edited_root := _get_edited_root() + if edited_root != null and edited_root.scene_file_path == file_path: + return MCPToolkitError.fail("EDITED_SCENE", + "cannot delete the currently-edited scene %s on Godot 4.2-4.4 (no tab-close API); open a different scene via scene.open first" % file_path) + warnings.append( + "phantom tab: scene tab for %s remains open; Godot 4.2-4.4 has no API to close tabs — it will vanish on editor restart or manual close" % file_path) + + var delete_result: Dictionary = await Helpers.delete_res_file_and_deindex(file_path) + delete_result["tab_closed"] = tab_closed + if tab_closed and tab_result.get("switched", false): + delete_result["hint"] = _TAB_CLOSE_NOISE_HINT + if not warnings.is_empty(): + delete_result["warnings"] = warnings + return delete_result + + +static func _cmd_scene_create_node(parameters: Dictionary) -> Dictionary: + var root := _get_edited_root() + if root == null: + return MCPToolkitError.fail("NO_SCENE", "no edited scene") + + var class_name_param := str(parameters.get("class_name", "")) + var parent_path := str(parameters.get("parent_path", "")) + parent_path = Helpers.normalize_editor_path(parent_path) + var requested_name := str(parameters.get("node_name", class_name_param)) + + if class_name_param.is_empty(): + return MCPToolkitError.fail("INVALID_PARAMS", "missing class_name") + + var resolved_kind := "" + var global_entry: Dictionary = {} + if ClassDB.class_exists(class_name_param): + resolved_kind = "native" + if not ClassDB.can_instantiate(class_name_param): + return MCPToolkitError.fail("INVALID_CLASS", + "class is not instantiable (abstract, virtual, or editor-only): %s" % class_name_param) + else: + for entry in ProjectSettings.get_global_class_list(): + if str(entry.get("class", "")) == class_name_param: + resolved_kind = "global" + global_entry = entry + break + if resolved_kind.is_empty(): + return MCPToolkitError.fail("INVALID_CLASS", + "unknown class %s; checked ClassDB (engine classes) and ProjectSettings.get_global_class_list() (GDScript class_name + C# [GlobalClass])" % class_name_param, MCPToolkitError.HINT_CLASS_NAME) + if not Helpers.class_descends_from(class_name_param, "Node"): + return MCPToolkitError.fail("INVALID_CLASS", + "%s is not a Node subclass (resolved base chain: %s); scene roots must descend from Node" % [ + class_name_param, Helpers.class_base_chain(class_name_param)]) + + var parent_node := root.get_node_or_null(parent_path) if not parent_path.is_empty() else root + if parent_node == null: + var extra := "" + if parent_path == root.name: + extra = "; to reference the scene root use parent_path=\".\" (not the root node's name)" + return MCPToolkitError.fail("NOT_FOUND", "parent not found: %s%s" % [parent_path, extra], MCPToolkitError.HINT_NODE_PATH) + + var existing := parent_node.get_node_or_null(NodePath(requested_name)) + if existing != null: + var class_match := false + var existing_script := existing.get_script() as Script + var existing_global_name := "" + if existing_script != null: + if existing_script.has_method("get_global_name"): + # 4.3+: script-direct, authoritative and current -- the same value the + # old static call returned (dynamic dispatch so 4.2 never resolves it). + existing_global_name = str(existing_script.call("get_global_name")) + else: + # 4.2: get_global_name is an unbound virtual -- invisible to GDScript. + # Reverse-lookup the class list by resource_path (4.2-safe; also C#). + for entry in ProjectSettings.get_global_class_list(): + if str(entry.get("path", "")) == existing_script.resource_path: + existing_global_name = str(entry.get("class", "")) + break + if resolved_kind == "native": + class_match = existing.is_class(class_name_param) + elif existing_script != null: + class_match = existing_global_name == class_name_param + if class_match: + var returned := {"status": "returned", "path": _path_in_scene(root, existing)} + # The existing node is handed back untouched: properties/layout_mode/ + # unique_name only apply while creating. Name any the caller passed so a + # silent no-op doesn't read as "applied". + var ignored := _passed_keys(parameters, ["properties", "layout_mode", "unique_name"]) + if not ignored.is_empty(): + returned["warning"] = "node already existed; ignored %s (only applied when a node is created, not returned)" % ", ".join(ignored) + return MCPToolkitSuccess.ok(returned) + var actual := existing_global_name if existing_global_name != "" else existing.get_class() + return MCPToolkitError.fail("CLASS_MISMATCH", + "node '%s' already exists under '%s' as %s, not %s; rename or remove it first" % [ + requested_name, _path_in_scene(root, parent_node), actual, class_name_param]) + + var instance: Node = null + if resolved_kind == "native": + instance = ClassDB.instantiate(class_name_param) + else: + var script_path := str(global_entry.get("path", "")) + var script = load(script_path) + if script == null: + return MCPToolkitError.fail("INVALID_CLASS", + "could not load script for %s at %s" % [class_name_param, script_path]) + instance = script.new() + if instance == null or not (instance is Node): + return MCPToolkitError.fail("INVALID_CLASS", "instantiate failed: %s" % class_name_param) + + instance.name = requested_name + + # Pre-coerce inline properties before UndoRedo (validation only). The raw wire + # value rides along so the post-set readback guard can give the bare-res://-string + # tagged-form hint (parity with node.set_property). + var properties_raw = parameters.get("properties", null) + var prop_coerced: Array = [] # [{name, value, old_value, raw}] — simple props (candidate for UndoRedo) + var prop_compound: Array = [] # [{name, raw_value}] — compound paths (direct set) + var prop_failed: Array = [] + if properties_raw != null and typeof(properties_raw) == TYPE_DICTIONARY: + for key in (properties_raw as Dictionary).keys(): + var prop_name := str(key) + # Compound paths (: or /) bypass coerce_for_property and use + # centralized set_property_compound (handles shader_parameter/ etc.) + if ":" in prop_name or "/" in prop_name: + prop_compound.append({"name": prop_name, "raw_value": properties_raw[key]}) + else: + var result := Helpers.coerce_for_property( + instance, prop_name, properties_raw[key]) + if result.get("ok", false): + prop_coerced.append({ + "name": prop_name, + "value": result["value"], + "old_value": instance.get(prop_name), + "raw": properties_raw[key], + }) + else: + prop_failed.append({ + "name": prop_name, + "error": str(result.get("error", "")), + }) + + parent_node.add_child(instance) + instance.set_owner(root) + # Set each simple prop, then read it back and classify the write. Coercion says a + # value is well-formed; it does NOT prove Object.set() stored it — set() is void and + # silently discards a wrong-type Variant. A DROPPED write is restored, reported in + # properties_failed, and excluded from both the UndoRedo rows and properties_set, so + # the response reflects only the writes that stored. Only landed props are undoable. + var landed: Array = [] # coerced props that stored (clean or engine-adjusted) + var warnings: Array[String] = [] + for prop in prop_coerced: + var prop_name: String = str(prop["name"]) + # Disarm the Godot 4.3 tooltip-timer UAF before an editor_description write + # (no-op otherwise). Usually a no-op here — the new row is (re)built deferred — + # but it keeps every built-in editor_description write behind one guard. + Helpers.disarm_tooltip_uaf(instance, prop_name) + instance.set(prop_name, prop["value"]) + var after = instance.get(prop_name) + # Bare res:// string on a Resource-typed property: a bare path silently fails to + # load, so steer to the tagged {type:"Resource", path:…} form BEFORE the generic + # drop guard (which would fire first with a vaguer message). Message mirrors + # node.set_property's scalar path so both tools report the same mistake identically. + var raw = prop["raw"] + if typeof(raw) == TYPE_STRING and str(raw).begins_with("res://") \ + and not (prop["value"] is Resource) and not (after is String): + instance.set(prop_name, prop["old_value"]) + prop_failed.append({"name": prop_name, "error": + "property '%s' expects a Resource, not a bare string path. " % prop_name + + "Use {\"type\": \"Resource\", \"path\": \"%s\"} as the value." % str(raw)}) + continue + var outcome := Helpers.describe_set_drop( + prop["old_value"], after, prop["value"], prop_name) + match str(outcome.get("status", "")): + "dropped": + # A bound setter may have Variant-converted the wrong type to a ZERO and + # stored it; restore the prior so the drop is truly non-destructive. + instance.set(prop_name, prop["old_value"]) + prop_failed.append({"name": prop_name, "error": str(outcome.get("error", ""))}) + "adjusted": + warnings.append(str(outcome.get("warning", ""))) + landed.append(prop) + _: + landed.append(prop) + var _undo := MCPToolkitUndoRedoAction.begin("create %s" % requested_name, parent_node) \ + .do_method(parent_node.add_child.bind(instance)) \ + .do_method(instance.set_owner.bind(root)) \ + .do_reference(instance) + for prop in landed: + _undo.do_property(instance, prop["name"], prop["value"]) + _undo.undo_property(instance, prop["name"], prop["old_value"]) + _undo.undo_method(parent_node.remove_child.bind(instance)) \ + .commit_recorded() + + # Apply compound properties after node is in tree (can't go through UndoRedo). + for prop in prop_compound: + var result := Helpers.set_property_compound(instance, prop["name"], prop["raw_value"]) + if not result.get("ok", false): + prop_failed.append({"name": prop["name"], "error": str(result.get("error", ""))}) + + # layout_mode: match Godot editor behavior for Control children of Containers. + # Default (-1) auto-detects: sets layout_mode=1 when parent is a Container. + var layout_mode_param: int = int(parameters.get("layout_mode", -1)) + if instance is Control: + if layout_mode_param >= 0: + instance.set("layout_mode", layout_mode_param) + elif parent_node is Container: + instance.set("layout_mode", 1) + + # unique_name: mark node for scene-unique access (%Name in scripts). + var unique_param = parameters.get("unique_name", null) + var response := MCPToolkitSuccess.ok({"status": "created", "path": _path_in_scene(root, instance)}) + + # Report inline property results. properties_set counts only props that actually + # landed (a dropped write is excluded); properties_failed lists both coercion + # rejections and post-set drops; adjusted writes stored, so they count as set and + # surface their delta in warnings. + if not landed.is_empty() or not prop_failed.is_empty(): + response["properties_set"] = landed.size() + if not prop_failed.is_empty(): + response["properties_failed"] = prop_failed + response["hint"] = "%d propert%s failed. Use node_set_property to retry." % [ + prop_failed.size(), + "y" if prop_failed.size() == 1 else "ies"] + if not warnings.is_empty(): + response["warnings"] = warnings + + if unique_param != null and (unique_param == true or str(unique_param).to_lower() == "true"): + var existing_unique := root.get_node_or_null("%" + str(instance.name)) + if existing_unique != null and existing_unique != instance: + response["warning"] = "Node '%s' was previously the unique '%s' — it has lost its unique status. %%Name references to it in scripts will now resolve to this new node instead." % [ + _path_in_scene(root, existing_unique), str(instance.name)] + instance.unique_name_in_owner = true + response["unique_name"] = true + return response + + +static func _cmd_scene_delete_node(parameters: Dictionary) -> Dictionary: + var root := _get_edited_root() + if root == null: + return MCPToolkitError.fail("NO_SCENE", "no edited scene") + + var node_path := str(parameters.get("node_path", "")) + node_path = Helpers.normalize_editor_path(node_path) + if node_path.is_empty(): + return MCPToolkitError.fail("INVALID_PARAMS", "missing node_path") + + var node := root.get_node_or_null(node_path) + if node == null: + return MCPToolkitError.fail("NOT_FOUND", "node not found: %s" % node_path, MCPToolkitError.HINT_NODE_PATH) + if node == root: + return MCPToolkitError.fail("INVALID_PATH", "cannot delete edited scene root") + + var parent := node.get_parent() + if parent == null: + return MCPToolkitError.fail("INTERNAL", "node has no parent: %s" % node_path) + parent.remove_child(node) + MCPToolkitUndoRedoAction.begin("delete %s" % node_path, parent) \ + .do_method(parent.remove_child.bind(node)) \ + .undo_method(parent.add_child.bind(node)) \ + .undo_method(node.set_owner.bind(root)) \ + .undo_reference(node) \ + .commit_recorded() + return MCPToolkitSuccess.ok({"path": node_path}) + + +static func _cmd_scene_instantiate(server: Node, parameters: Dictionary) -> Dictionary: + var root := _get_edited_root() + if root == null: + return MCPToolkitError.fail("NO_SCENE", "no open scene; use scene.open or scene.create first") + + var parent_path := str(parameters.get("parent_path", "")) + parent_path = Helpers.normalize_editor_path(parent_path) + var packed_path := str(parameters.get("scene_path", "")) + + if parent_path.is_empty() or packed_path.is_empty(): + return MCPToolkitError.fail("INVALID_PARAMS", "missing parent_path or scene_path") + + var parent_node := root.get_node_or_null(parent_path) + if parent_node == null: + return MCPToolkitError.fail("NOT_FOUND", + "no node at parent_path %s (must be under the currently-edited scene root)" % parent_path, MCPToolkitError.HINT_NODE_PATH) + + var guard := FileGuard.resolve_safe(packed_path) + if guard["error"] != null: + return MCPToolkitError.fail("PATH_DENIED", str(guard["reason"])) + if packed_path.get_extension().to_lower() != "tscn": + return MCPToolkitError.fail("INVALID_PATH", + "scene.instantiate only instantiates .tscn files (got %s); use resource.write for .tres, script.write for .gd/.cs" % packed_path) + if not FileAccess.file_exists(packed_path): + return MCPToolkitError.fail("NOT_FOUND", + "no scene file at %s; use scene.create first" % packed_path, MCPToolkitError.HINT_FILE_PATH) + var packed := ResourceLoader.load(packed_path) + if packed == null: + return MCPToolkitError.fail("LOAD_FAILED", + "ResourceLoader.load returned null for %s (corrupt file or dependency error — check editor_get_console)" % packed_path) + if not (packed is PackedScene): + return MCPToolkitError.fail("INVALID_CLASS", + "file at %s is not a PackedScene (got %s); scene.instantiate only works on .tscn files" % [ + packed_path, packed.get_class()]) + + # Batch mode: instances array present → instantiate N copies in one UndoRedo action. + var instances_raw = parameters.get("instances", null) + if typeof(instances_raw) == TYPE_ARRAY and (instances_raw as Array).size() > 0: + return _batch_instantiate(server, root, parent_node, packed as PackedScene, + packed_path, parent_path, instances_raw as Array) + + # Single mode (original behavior). + var as_name := str(parameters.get("as_name", "")) + var transform_raw = parameters.get("transform", {}) + var transform: Dictionary = transform_raw if typeof(transform_raw) == TYPE_DICTIONARY else {} + + var target_name := as_name if as_name != "" else (packed as PackedScene).get_state().get_node_name(0) + if parent_node.has_node(NodePath(target_name)): + if as_name != "": + # Explicit name — idempotent return. + var existing_node := parent_node.get_node(NodePath(target_name)) + var returned := { + "status": "returned", + "path": _path_in_scene(root, existing_node), + "class_name": existing_node.get_class(), + } + # transform applies only to a freshly-instantiated node; the existing + # one is returned as-is. Disclose a passed transform so it doesn't read + # as silently applied. + if not transform.is_empty(): + returned["warning"] = "node already existed; ignored transform (only applied to a newly-instantiated node)" + return MCPToolkitSuccess.ok(returned) + # Auto-rename on collision (Player, Player2, Player3...) — + # matches Godot editor's own drag-drop naming convention. + var suffix := 2 + while parent_node.has_node(NodePath(target_name + str(suffix))): + suffix += 1 + target_name = target_name + str(suffix) + + var instance: Node = (packed as PackedScene).instantiate() + if instance == null: + return MCPToolkitError.fail("LOAD_FAILED", + "PackedScene.instantiate returned null for %s" % packed_path) + + instance.name = target_name + + if not transform.is_empty(): + for key in transform.keys(): + var outcome := _coerce_transform_value(transform[key]) + if not outcome["ok"]: + # instance is instantiated but not yet in the tree — free the + # orphan before bailing so a malformed transform can't leak a node. + instance.queue_free() + return MCPToolkitError.fail("INVALID_PARAMS", str(outcome["error"])) + instance.set(str(key), outcome["value"]) + + # Only set owner on instance root — child nodes keep their internal + # ownership from PackedScene. _set_owner_recursive caused full property + # expansion, breaking Godot's scene inheritance model. + parent_node.add_child(instance) + instance.set_owner(root) + MCPToolkitUndoRedoAction.begin("instantiate %s under %s" % [packed_path, parent_path], parent_node) \ + .do_method(parent_node.add_child.bind(instance)) \ + .do_method(instance.set_owner.bind(root)) \ + .do_reference(instance) \ + .undo_method(parent_node.remove_child.bind(instance)) \ + .commit_recorded() + + return MCPToolkitSuccess.ok({ + "status": "created", + "path": _path_in_scene(root, instance), + "class_name": instance.get_class(), + }) + + +## Coerce one transform value (position/rotation/scale) for an instantiate call. +## Returns {"ok": true, "value": } on success, else +## {"ok": false, "error": }. +## +## A bare {x, y} / {x, y, z} dict carries no "type" tag, so [method Coerce.coerce_value] +## returns it as a plain dict (not a typed Vector). set()-ing that dict onto a Vector +## property is a silent no-op, so it is rejected here with a hint to tag it — an honest +## failure in place of a dropped transform. +static func _coerce_transform_value(raw: Variant) -> Dictionary: + var coerced = Coerce.coerce_value(raw) + if typeof(coerced) == TYPE_DICTIONARY: + if (coerced as Dictionary).has("_coerce_error"): + return {"ok": false, "error": str(coerced["_coerce_error"])} + return {"ok": false, "error": + "position/rotation/scale expect a tagged Vector2 {type:'Vector2', x, y} (or Vector3 {type:'Vector3', x, y, z} for 3D) — a bare {x, y} is not coerced"} + return {"ok": true, "value": coerced} + + +static func _batch_instantiate( + server: Node, root: Node, parent_node: Node, packed: PackedScene, + packed_path: String, parent_path: String, instances: Array, +) -> Dictionary: + var node_refs: Array = [] + # Per-entry outcome record: every entry — success OR failure — + # gets a dict so a partial batch no longer hides its failures. Built parallel + # to `node_refs`; success entries are backfilled with their post-commit path + # below. `results[i]` lines up with `instances[i]`. `_result_slots` maps each + # surviving node to its slot so the deferred path read patches the right row. + var results: Array = [] + var _result_slots: Array = [] + var _undo := MCPToolkitUndoRedoAction.begin( + "batch instantiate %d × %s" % [instances.size(), packed_path], parent_node) + + for i in instances.size(): + var entry = instances[i] + var inst_dict: Dictionary = entry if typeof(entry) == TYPE_DICTIONARY else {} + var inst_name := str(inst_dict.get("name", "")) + var instance: Node = packed.instantiate() + if instance == null: + # Previously a silent `continue` — the failed entry vanished. Record it. + results.append({ + "index": i, + "success": false, + "name": inst_name, + "error": "PackedScene.instantiate returned null for %s" % packed_path, + }) + continue + + if not inst_name.is_empty(): + instance.name = inst_name + + # Per-key coerce failures used to `continue` the inner loop silently — the + # node still lands but the key was dropped invisibly. Capture each into + # `property_errors` on this (succeeding) entry instead of losing it. + var property_errors: Array = [] + + # Apply transform properties (position, rotation, scale). + for key in ["position", "rotation", "scale"]: + if inst_dict.has(key): + var outcome := _coerce_transform_value(inst_dict[key]) + if not outcome["ok"]: + property_errors.append({"property": key, "error": str(outcome["error"])}) + continue + instance.set(key, outcome["value"]) + + # Apply arbitrary property overrides (e.g. exports like key_type). + # Compound paths (: or /) use the centralized handler. + var props = inst_dict.get("properties", null) + if typeof(props) == TYPE_DICTIONARY: + for key in (props as Dictionary).keys(): + var prop_name := str(key) + if ":" in prop_name or "/" in prop_name: + Helpers.set_property_compound(instance, prop_name, props[key]) + else: + var coerced = Coerce.coerce_value(props[key]) + if typeof(coerced) == TYPE_DICTIONARY and (coerced as Dictionary).has("_coerce_error"): + property_errors.append({"property": prop_name, "error": str(coerced["_coerce_error"])}) + continue + instance.set(prop_name, coerced) + + # Only set owner on instance root (same as single-instance path). + parent_node.add_child(instance) + instance.set_owner(root) + _undo.do_method(parent_node.add_child.bind(instance)) \ + .do_method(instance.set_owner.bind(root)) \ + .do_reference(instance) \ + .undo_method(parent_node.remove_child.bind(instance)) + + node_refs.append(instance) + # Success row — path/class are filled after commit (see below). Carry any + # per-key coerce failures so they're no longer invisible. + var slot := {"index": i, "success": true} + if not property_errors.is_empty(): + slot["property_errors"] = property_errors + results.append(slot) + _result_slots.append(slot) + + _undo.commit_recorded() + + # Collect paths AFTER commit_action — instances are now in the tree, + # so get_path_to() can find the common parent. + var created: Array = [] + for idx in node_refs.size(): + var inst: Node = node_refs[idx] + var inst_path := _path_in_scene(root, inst) + var inst_class := inst.get_class() + var resolved_name := String(inst.name) + created.append({ + "path": inst_path, + "class": inst_class, + "name": resolved_name, + }) + # Backfill the matching success row with its now-resolvable path/class/name. + var slot: Dictionary = _result_slots[idx] + slot["path"] = inst_path + slot["class"] = inst_class + slot["name"] = resolved_name + + # Roll any failed entries up into a top-level `failed` + `hint` (no-op when all + # succeeded — `instances`/`count` stay exactly as before). + return MCPToolkitSuccess.ok(Helpers.summarize_batch({"status": "created", + "count": created.size(), "instances": created, "results": results}, "results")) + + +static func _cmd_create_inherited(parameters: Dictionary) -> Dictionary: + var err = MCPToolkitError.require(parameters, ["file_path", "base_scene"]) + if err != null: + return err + + var file_path := str(parameters.get("file_path", "")) + var base_scene := str(parameters.get("base_scene", "")) + var root_name := str(parameters.get("root_name", "")) + + var guard := FileGuard.resolve_safe(file_path) + if guard["error"] != null: + return MCPToolkitError.fail("PATH_DENIED", str(guard["reason"])) + if not file_path.ends_with(".tscn"): + return MCPToolkitError.fail("INVALID_PARAMS", "file_path must end with .tscn") + + var base_guard := FileGuard.resolve_safe(base_scene) + if base_guard["error"] != null: + return MCPToolkitError.fail("PATH_DENIED", str(base_guard["reason"])) + if not ResourceLoader.exists(base_scene): + return MCPToolkitError.fail("NOT_FOUND", "base scene not found: %s" % base_scene) + + if root_name.is_empty(): + var base := ResourceLoader.load(base_scene) as PackedScene + if base == null: + return MCPToolkitError.fail("INTERNAL", "failed to load base scene: %s" % base_scene) + var instance := base.instantiate() + root_name = instance.name + instance.free() + + # Idempotency: check if target already exists. + if FileAccess.file_exists(file_path): + return MCPToolkitSuccess.ok({"status": "returned", "file_path": file_path, + "base_scene": base_scene, "root_name": root_name, + "message": "file already exists — no changes made"}) + + var dir_result := Helpers.ensure_parent_dir(file_path, "scene.create_inherited") + if dir_result.has("error"): + return dir_result + + var tscn_text := '[gd_scene load_steps=2 format=3]\n\n' + tscn_text += '[ext_resource type="PackedScene" path="%s" id="1"]\n\n' % base_scene + tscn_text += '[node name="%s" instance=ExtResource("1")]\n' % root_name + + var file := FileAccess.open(file_path, FileAccess.WRITE) + if file == null: + return MCPToolkitError.fail("INTERNAL", + "cannot write to %s: error %d" % [file_path, FileAccess.get_open_error()]) + file.store_string(tscn_text) + file.close() + + await Helpers.ensure_file_indexed(file_path) + + return MCPToolkitSuccess.ok({"file_path": file_path, "base_scene": base_scene, "root_name": root_name}) + + +static func _cmd_scene_diff(server: Node, parameters: Dictionary) -> Dictionary: + if not parameters.has("before"): + return MCPToolkitError.fail("INVALID_PARAMS", "missing before") + var before = parameters.get("before") + var after = parameters.get("after", null) + if after == null: + var root := _get_edited_root() + if root == null: + return MCPToolkitError.fail("NO_SCENE", "no edited scene") + after = _walk_tree(root, root, -1, false) + var before_string := JSON.stringify(before, " ", true) + var after_string := JSON.stringify(after, " ", true) + if before_string == after_string: + return MCPToolkitSuccess.ok({"changed": false, "diff": "", "added": 0, "removed": 0}) + var before_lines := before_string.split("\n", false) + var after_lines := after_string.split("\n", false) + var before_set := {} + for line in before_lines: + before_set[line] = true + var after_set := {} + for line in after_lines: + after_set[line] = true + var diff_parts := PackedStringArray() + var removed := 0 + for line in before_lines: + if not after_set.has(line): + diff_parts.append("- " + line) + removed += 1 + var added := 0 + for line in after_lines: + if not before_set.has(line): + diff_parts.append("+ " + line) + added += 1 + return MCPToolkitSuccess.ok({ + "changed": true, + "diff": "\n".join(diff_parts), + "added": added, + "removed": removed, + }) + + +static func _cmd_scene_query(parameters: Dictionary) -> Dictionary: + var class_filter = parameters.get("class_filter", null) + var group_filter = parameters.get("group_filter", null) + var name_pattern = parameters.get("name_pattern", null) + var property_filters = parameters.get("property_filters", null) + var root_path = parameters.get("root_path", null) + var max_depth: int = int(parameters.get("max_depth", -1)) + var include_properties = parameters.get("include_properties", null) + # Never trust the caller's window bounds: floor offset at 0 and cap limit so a + # single page can't exceed the transport frame budget (a huge include_properties + # page is the residual bloat vector). The clamp is disclosed in-band below. + var requested_limit: int = int(parameters.get("limit", 50)) + var limit: int = mini(requested_limit, _QUERY_MAX_LIMIT) + var limit_clamped := limit < requested_limit + var offset: int = maxi(0, int(parameters.get("offset", 0))) + + # At least one filter must be provided + if class_filter == null and group_filter == null and name_pattern == null \ + and (property_filters == null \ + or (typeof(property_filters) == TYPE_ARRAY and property_filters.size() == 0)): + return MCPToolkitError.fail("INVALID_PARAMS", + "At least one filter is required: class_filter, group_filter, name_pattern, or property_filters") + + var edited_scene := EditorInterface.get_edited_scene_root() + if edited_scene == null: + return MCPToolkitError.fail("NO_SCENE", "No scene is currently open in the editor") + + # Determine root node + var root: Node = edited_scene + if root_path != null and str(root_path) != "": + var rp := str(root_path) + rp = Helpers.normalize_editor_path(rp) + root = edited_scene.get_node_or_null(NodePath(rp)) + if root == null: + return MCPToolkitError.fail("NOT_FOUND", "Root node not found: " + rp) + + # Single ordered DFS pre-order pass counts every match and materializes only the + # [offset, offset+limit) window (see _query_recursive). total_ref[0] is the + # running match count threaded by reference. + var results: Array[Dictionary] = [] + var total_ref: Array[int] = [0] + _query_recursive(root, edited_scene, class_filter, group_filter, name_pattern, + property_filters, include_properties, max_depth, 0, offset, limit, total_ref, results) + var total_matches := total_ref[0] + + return _build_query_page(results, offset, limit, total_matches, limit_clamped) + + +## Assembles the self-describing pagination envelope for scene.query. +## +## [param limit] is the effective (already-clamped) page size; [param limit_clamped] +## records that the caller's request was capped, so this discloses the flag and appends a +## clamp clause to the resume hint. Routes through the shared [code]Pagination[/code] +## builder so the invariants and the [code]next_offset[/code] resume field match every +## other paginating tool; the hint wording is scene.query's own. +static func _build_query_page(results: Array[Dictionary], offset: int, limit: int, + total_matches: int, limit_clamped: bool) -> Dictionary: + var returned := results.size() + var has_more := offset + returned < total_matches + var hint := "" + if has_more: + hint = "more matches remain — re-call scene_query with offset = next_offset (%d) until has_more is false" % (offset + returned) + var extras: Dictionary = {} + if limit_clamped: + extras["limit_clamped"] = true + var clamp_clause := "requested limit exceeds max %d; returned %d — page with next_offset, or add filters to narrow" % [ + _QUERY_MAX_LIMIT, limit] + hint = clamp_clause if hint.is_empty() else "%s. %s" % [hint, clamp_clause] + var page := Modules.Pagination.list_page( + {"nodes": results, "offset": offset, "limit": limit}, + results, offset, "matches", total_matches, hint, extras) + return MCPToolkitSuccess.ok(page) + + +static func _query_recursive(node: Node, scene_root: Node, class_filter, group_filter, + name_pattern, property_filters, include_properties, max_depth: int, + current_depth: int, offset: int, limit: int, total_ref: Array[int], + results: Array[Dictionary]) -> void: + var matches := true + + # Class filter (inheritance-aware) + if matches and class_filter != null: + var cf := str(class_filter) + if not node.is_class(cf): + matches = false + + # Group filter + if matches and group_filter != null: + if not node.is_in_group(str(group_filter)): + matches = false + + # Name pattern (glob) + if matches and name_pattern != null: + if not node.name.match(str(name_pattern)): + matches = false + + # Property filters + if matches and property_filters != null and typeof(property_filters) == TYPE_ARRAY: + for pf in property_filters: + if typeof(pf) != TYPE_DICTIONARY: + continue + var prop_name = pf.get("property", "") + var expected_value = pf.get("value", null) + var op := str(pf.get("operator", "eq")) + var actual_value = node.get(StringName(str(prop_name))) + if not _compare_values(actual_value, expected_value, op): + matches = false + break + + if matches: + # Count every match for total_matches; serialize the entry only when this + # match's 0-based index falls in the requested window. The count and the + # window both derive from this one ordered pass, so paging stays coherent. + var match_index := total_ref[0] + total_ref[0] = match_index + 1 + if match_index >= offset and results.size() < limit: + var entry: Dictionary = { + "path": str(scene_root.get_path_to(node)), + "class": node.get_class(), + "name": str(node.name), + } + if include_properties != null and typeof(include_properties) == TYPE_ARRAY: + for prop_name in include_properties: + entry[str(prop_name)] = Coerce.serialize_value( + node.get(StringName(str(prop_name)))) + results.append(entry) + + # Recurse children in child-index order — the only ordering; no early-stop, so + # every match past the window is still counted into total_matches. + if max_depth < 0 or current_depth < max_depth: + for child in node.get_children(): + _query_recursive(child, scene_root, class_filter, group_filter, name_pattern, + property_filters, include_properties, max_depth, current_depth + 1, + offset, limit, total_ref, results) + + +static func _compare_values(actual, expected, op: String) -> bool: + match op: + "eq": + return str(actual) == str(expected) # String comparison for cross-type safety + "ne": + return str(actual) != str(expected) + "gt": + if actual is float or actual is int: + return float(actual) > float(expected) + return false + "lt": + if actual is float or actual is int: + return float(actual) < float(expected) + return false + return false diff --git a/addons/godot_mcp_toolkit/commands/scene_commands.gd.uid b/addons/godot_mcp_toolkit/commands/scene_commands.gd.uid new file mode 100644 index 0000000..948f030 --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/scene_commands.gd.uid @@ -0,0 +1 @@ +uid://c44f8eesq7s78 diff --git a/addons/godot_mcp_toolkit/commands/script_commands.gd b/addons/godot_mcp_toolkit/commands/script_commands.gd new file mode 100644 index 0000000..bcfb78f --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/script_commands.gd @@ -0,0 +1,445 @@ +@tool +extends RefCounted +## script.* command handlers — read, write, surgical edit, delete, and offline +## check for .gd/.cs/.gdshader/.gdshaderinc. + +const Modules := preload("res://addons/godot_mcp_toolkit/core/modules.gd") +const FileGuard = Modules.FileGuard +const Untrusted = Modules.Untrusted +const Helpers = Modules.CommandHelpers + +const ALLOWED_EXTENSIONS: Array[String] = ["gd", "cs", "gdshader", "gdshaderinc"] + +static var _autoload_hint_re: RegEx = _compile_autoload_hint_re() +static var _preload_hint_re: RegEx = _compile_preload_hint_re() + +static func _compile_autoload_hint_re() -> RegEx: + var re := RegEx.new() + re.compile('Identifier "(\\w+)" not declared') + return re + +static func _compile_preload_hint_re() -> RegEx: + var re := RegEx.new() + re.compile('(?:[Cc]ould not p|[Pp])reload (?:resource )?(?:file|script|scene) "([^"]+)"') + return re + + +static func register(registry: MCPToolkitCommandRegistry, server: Node) -> void: + registry.add("script.read", func(parameters: Dictionary) -> Dictionary: + return _cmd_script_read(parameters) + , MCPToolkitCommandOptions.new().mark_read_only().mark_scene_independent()) + registry.add("script.write", func(parameters: Dictionary) -> Dictionary: + return await _cmd_script_write(server, parameters) + , MCPToolkitCommandOptions.new().mark_scene_independent()) + registry.add("script.edit", func(parameters: Dictionary) -> Dictionary: + return await _cmd_script_edit(server, parameters) + , MCPToolkitCommandOptions.new().mark_scene_independent()) + registry.add("script.delete", func(parameters: Dictionary) -> Dictionary: + return await _cmd_script_delete(parameters) + , MCPToolkitCommandOptions.new().mark_scene_independent()) + registry.add("script.check", func(parameters: Dictionary) -> Dictionary: + return _cmd_script_check(parameters) + , MCPToolkitCommandOptions.new().mark_read_only().mark_scene_independent()) + + +# -- Commands ----------------------------------------------------------------- + + +static func _cmd_script_read(parameters: Dictionary) -> Dictionary: + var err = MCPToolkitError.require(parameters, ["file_path"]) + if err != null: + return err + var file_path := str(parameters.get("file_path", "")) + var guard := FileGuard.resolve_safe(file_path) + if guard["error"] != null: + return MCPToolkitError.fail("PATH_DENIED", str(guard["reason"])) + if not FileAccess.file_exists(file_path): + return MCPToolkitError.fail("NOT_FOUND", "file not found: %s" % file_path, MCPToolkitError.HINT_FILE_PATH) + var content := FileAccess.get_file_as_string(file_path) + var open_error := FileAccess.get_open_error() + if open_error != OK: + return MCPToolkitError.fail("READ_FAILED", + "FileAccess error %d reading %s" % [open_error, file_path]) + + # Range read: if start_line is present, return a line slice. + if parameters.has("start_line"): + var start_line := int(parameters.get("start_line", 0)) + var end_line := int(parameters.get("end_line", start_line)) + if start_line < 1: + return MCPToolkitError.fail("INVALID_PARAMS", + "start_line must be >= 1 (got %d)" % start_line) + if end_line < start_line: + return MCPToolkitError.fail("INVALID_PARAMS", + "end_line must be >= start_line (got %d < %d)" % [end_line, start_line]) + var lines := content.split("\n") + var total_lines := lines.size() + var clamped_start := mini(start_line, total_lines) + var clamped_end := mini(end_line, total_lines) + var slice := lines.slice(clamped_start - 1, clamped_end) + var result_text := "\n".join(slice) + var result_bytes := result_text.to_utf8_buffer().size() + var cap_kb: int = ProjectSettings.get_setting("mcp_toolkit/limits/script_read_cap_kb", 256) + if result_bytes > cap_kb * 1024: + return MCPToolkitError.fail("FILE_TOO_LARGE", + "slice exceeds %d KB response cap; narrow the line range" % cap_kb) + # Uniform pagination contract in line units: has_more = the last returned line + # precedes EOF; when so, the builder adds next_start_line (1-based resume line = + # clamped_end + 1) beside the prose loop hint composed here. + var range_has_more := clamped_end < total_lines + var range_hint := "" + if range_has_more: + range_hint = "more lines remain — re-call script.read with start_line = next_start_line (%d) until has_more is false" % (clamped_end + 1) + return MCPToolkitSuccess.ok(Modules.Pagination.line_page( + { + "content": Untrusted.wrap("script", file_path, result_text), + "start_line": clamped_start, + "end_line": clamped_end, + }, + clamped_end, slice.size(), total_lines, range_hint)) + + # Full read with size cap. + var content_bytes := content.to_utf8_buffer().size() + var cap_kb: int = ProjectSettings.get_setting("mcp_toolkit/limits/script_read_cap_kb", 256) + if content_bytes > cap_kb * 1024: + var size_err := MCPToolkitError.fail("FILE_TOO_LARGE", + "file exceeds %d KB response cap" % cap_kb) + size_err["total_bytes"] = content_bytes + size_err["hint"] = "re-call script_read with start_line / end_line" + return size_err + # Full file returned ⇒ never has_more. returned == total_lines and has_more:false + # complete the uniform pagination contract so both read shapes carry the same fields. + var full_lines := content.split("\n") + return MCPToolkitSuccess.ok(Modules.Pagination.build( + {"content": Untrusted.wrap("script", file_path, content)}, + "lines", full_lines.size(), full_lines.size(), false, "", 0, "")) + + + +static func _cmd_script_write(server: Node, parameters: Dictionary) -> Dictionary: + var err = MCPToolkitError.require(parameters, ["file_path"]) + if err != null: + return err + var file_path := str(parameters.get("file_path", "")) + var guard := FileGuard.resolve_safe(file_path) + if guard["error"] != null: + return MCPToolkitError.fail("PATH_DENIED", str(guard["reason"])) + var write_extension := file_path.get_extension().to_lower() + if not (write_extension in ALLOWED_EXTENSIONS): + return MCPToolkitError.fail("INVALID_PATH", + "script.write only writes .gd, .cs, .gdshader, or .gdshaderinc files (got %s); use scene.create for .tscn, resource.write for .tres/.res, or a different tool for other file types" % file_path) + if not parameters.has("content"): + return MCPToolkitError.fail("INVALID_PARAMS", "missing content") + var content := str(parameters.get("content", "")) + + var dir_result := Helpers.ensure_parent_dir(file_path, "script.write") + if dir_result.has("error"): + return dir_result + var dirs_created: bool = dir_result["dirs_created"] + + var existed := FileAccess.file_exists(file_path) + var prior_content := "" + if existed: + prior_content = FileAccess.get_file_as_string(file_path) + var read_error := FileAccess.get_open_error() + if read_error != OK: + return MCPToolkitError.fail("READ_FAILED", + "could not read prior content of %s (err %d)" % [file_path, read_error]) + + var result := await _commit_content(server, file_path, content, prior_content, existed, write_extension) + if result.has("error"): + return result + if dirs_created: + result["dirs_created"] = true + return result + + +static func _cmd_script_edit(server: Node, parameters: Dictionary) -> Dictionary: + var err = MCPToolkitError.require(parameters, ["file_path", "old_string"]) + if err != null: + return err + var file_path := str(parameters.get("file_path", "")) + var guard := FileGuard.resolve_safe(file_path) + if guard["error"] != null: + return MCPToolkitError.fail("PATH_DENIED", str(guard["reason"])) + var edit_extension := file_path.get_extension().to_lower() + if not (edit_extension in ALLOWED_EXTENSIONS): + return MCPToolkitError.fail("INVALID_PATH", + "script.edit only edits .gd, .cs, .gdshader, or .gdshaderinc files (got %s); use scene.create for .tscn, resource.write for .tres/.res, or a different tool for other file types" % file_path) + # new_string is required-present but "" is valid (an empty replacement deletes the + # span) — so it can't go through require(), which rejects an empty string. Check + # presence directly. + if not parameters.has("new_string"): + return MCPToolkitError.fail("INVALID_PARAMS", "missing new_string") + var old_string := str(parameters.get("old_string", "")) + var new_string := str(parameters.get("new_string", "")) + # Fail fast on a no-op / malformed edit before touching the file: a substitution + # that changes nothing would still churn undo/index/diagnostics for no effect. + if old_string == new_string: + return MCPToolkitError.fail("INVALID_PARAMS", + "old_string and new_string are identical — the edit would change nothing") + var replace_all := bool(parameters.get("replace_all", false)) + + if not FileAccess.file_exists(file_path): + return MCPToolkitError.fail("NOT_FOUND", "no file at %s" % file_path, MCPToolkitError.HINT_FILE_PATH) + var prior_content := FileAccess.get_file_as_string(file_path) + var read_error := FileAccess.get_open_error() + if read_error != OK: + return MCPToolkitError.fail("READ_FAILED", + "FileAccess error %d reading %s" % [read_error, file_path]) + + var match_count := prior_content.count(old_string) + if match_count == 0: + return MCPToolkitError.fail("NOT_FOUND", + "old_string not found in %s" % file_path, + "Re-read the file (script.read) and copy old_string byte-for-byte; whitespace and indentation must match exactly.") + if match_count > 1 and not replace_all: + return MCPToolkitError.fail("NOT_UNIQUE", + "old_string matches %d times in %s" % [match_count, file_path], + "Add surrounding context so old_string identifies one location, or set replace_all:true to replace all %d." % match_count) + + var new_content: String + var replacements: int + if replace_all: + new_content = prior_content.replace(old_string, new_string) + replacements = match_count + else: + # Exactly one match: splice at the found offset rather than String.replace (which + # would substitute every occurrence), leaving every other byte untouched. + var at := prior_content.find(old_string) + new_content = prior_content.substr(0, at) + new_string + prior_content.substr(at + old_string.length()) + replacements = 1 + + # The file existed (checked above), so the undo restores prior_content. + var result := await _commit_content(server, file_path, new_content, prior_content, true, edit_extension) + if result.has("error"): + return result + result["replacements"] = replacements + return result + + +## Writes [param content] to [param file_path], wrapping it in an editor UndoRedo +## action, reindexing the file, and — for a .gd file — attaching inline +## diagnostics plus the stale-live-instance hint. Shared by [method _cmd_script_write] +## (whole-file) and [method _cmd_script_edit] (surgical) so both take the identical +## write/undo/index/diagnose pipeline. [param prior_content] is the file's content +## before this write (empty when [param existed] is false) — the undo restores it, +## or deletes the file when it did not exist. [param extension] is the lowercased +## file extension, used to gate the .gd-only diagnostics. +## Returns the success envelope [code]{bytes, undoable, indexed, valid?, diagnostics?, hint?}[/code], +## or a [method MCPToolkitError.fail] envelope on a write failure. +static func _commit_content(server: Node, file_path: String, content: String, + prior_content: String, existed: bool, extension: String) -> Dictionary: + var write_error := _write_file_raw(file_path, content) + if write_error != OK: + return MCPToolkitError.fail("WRITE_FAILED", + "could not open %s for write (err %d)" % [file_path, write_error]) + + var _undo := MCPToolkitUndoRedoAction.begin("script_write: %s" % file_path) \ + .do_method(server.undo_helpers._write_file_silent.bind(file_path, content)) + var undoable := _undo.is_active() + if existed: + _undo.undo_method(server.undo_helpers._write_file_silent.bind(file_path, prior_content)) + else: + _undo.undo_method(server.undo_helpers._delete_file_silent.bind(file_path)) + _undo.commit_recorded() + + var index_result := await Helpers.ensure_file_indexed(file_path) + + var bytes_written := content.to_utf8_buffer().size() + var result := MCPToolkitSuccess.ok({"bytes": bytes_written, "undoable": undoable, + "indexed": index_result["indexed"]}) + + # Inline GDScript diagnostics — same validation as script_check. + if extension == "gd": + var validation := _validate_gdscript(content) + result["valid"] = validation["valid"] + result["diagnostics"] = validation["diagnostics"] + + # Proactive stale-live-instance hint. Editing an EXISTING .gd + # that compiled OK on Godot < 4.4 won't reach a live instance until relaunch + # (added members AND changed bodies; a fresh node doesn't help either — + # empirically characterised, boundary 4.3->4.4). Append to the response hint + # (validation guidance first, stale nudge in the recency slot). + var version := Modules.VersionUtils.get_engine_version_ints() + if Modules.StaleInstanceHint.should_warn_on_write(existed, validation["valid"], extension, version.x, version.y): + result["hint"] = Modules.StaleInstanceHint.write_hint(Modules.VersionUtils.get_engine_version_pair()) + + return result + + +static func _cmd_script_delete(parameters: Dictionary) -> Dictionary: + var err = MCPToolkitError.require(parameters, ["file_path"]) + if err != null: + return err + var file_path := str(parameters.get("file_path", "")) + var guard := FileGuard.resolve_safe(file_path) + if guard["error"] != null: + return MCPToolkitError.fail("PATH_DENIED", str(guard["reason"])) + var extension := file_path.get_extension().to_lower() + if not (extension in ALLOWED_EXTENSIONS): + return MCPToolkitError.fail("INVALID_PATH", + "script.delete only removes .gd, .cs, .gdshader, or .gdshaderinc files (got %s); use scene.delete for .tscn, resource.delete for .tres/.res, or a different tool for other file types" % file_path) + if not FileAccess.file_exists(file_path): + return MCPToolkitError.fail("NOT_FOUND", "no file at %s" % file_path, MCPToolkitError.HINT_FILE_PATH) + var delete_result: Dictionary = await Helpers.delete_res_file_and_deindex(file_path) + return delete_result + + +# -- File I/O helpers (referenced by UndoRedo via server node) ---------------- + + +static func _cmd_script_check(parameters: Dictionary) -> Dictionary: + var file_path := str(parameters.get("file_path", "")) + if file_path == "": + return MCPToolkitError.fail("INVALID_PARAMS", "file_path is required") + var guard := FileGuard.resolve_safe(file_path) + if guard["error"] != null: + return MCPToolkitError.fail("PATH_DENIED", str(guard["reason"])) + var extension := file_path.get_extension().to_lower() + if extension != "gd": + return MCPToolkitError.fail("INVALID_PARAMS", + "script.check only supports .gd files (got .%s)" % extension) + if not FileAccess.file_exists(file_path): + return MCPToolkitError.fail("NOT_FOUND", "no file at %s" % file_path, MCPToolkitError.HINT_FILE_PATH) + + var content := FileAccess.get_file_as_string(file_path) + var read_error := FileAccess.get_open_error() + if read_error != OK: + return MCPToolkitError.fail("READ_FAILED", + "FileAccess error %d reading %s" % [read_error, file_path]) + + var validation := _validate_gdscript(content) + return MCPToolkitSuccess.ok({ + "file_path": file_path, + "valid": validation["valid"], + "diagnostics": validation["diagnostics"], + }) + + +## Validate GDScript source via GDScript.new().reload() — safe in-process parse. +## DO NOT use ResourceLoader.load() with CACHE_MODE_IGNORE here: +## it corrupts already-loaded scripts on ALL Godot versions (P-056). +## Shared by script_write (inline diagnostics) and script_check. +## +## Diagnostics carry only real fields: the error entry gains "line" (the actual +## parse/compile error line) when the 4.5+ Logger capture recovered it from the +## reload error; on 4.2-4.4 (file-tail capture — no structured line) the key is +## omitted, never a fabricated 0. "col" is never emitted — columns are +## lsp_diagnostics' domain. +static func _validate_gdscript(source: String) -> Dictionary: + # Strip class_name to prevent false-positive conflict (P-053). + # GDScript.new().reload() registers a second copy of the name, colliding + # with the already-registered global class. Blanking the line preserves + # line numbers so any real errors still report correct positions. + var lines := source.split("\n") + for i in lines.size(): + if lines[i].strip_edges().begins_with("class_name "): + lines[i] = "" + break + # Snapshot LogBuffer position so we can scan errors produced by reload(). + # _next_id is the ID the next pushed entry will receive; since_id uses + # "entries with id > since_id", so subtract 1 to include that first entry. + var pre_id: int = Modules.LogBuffer._next_id - 1 + var script := GDScript.new() + script.source_code = "\n".join(lines) + var is_valid := script.reload(false) == OK + + var diagnostics: Array = [] + if not is_valid: + var error_diagnostic := { + "severity": "error", + "message": _compile_error_message(Modules.VersionUtils.get_engine_version_pair()), + } + # The real error line, recovered from the Logger capture of the reload + # error this call just triggered (an in-memory script reports under a + # synthetic "gdscript://…" path, so the latch match is ours — the + # snapshot-to-scan window runs synchronously on the main thread). -1 + # (nothing recovered — 4.2-4.4, or a failed Logger hook) omits the key. + var real_line: int = Modules.LogBuffer.find_script_error_line_since(pre_id) + if real_line > 0: + error_diagnostic["line"] = real_line + diagnostics.append(error_diagnostic) + # Scan reload errors for unresolved identifiers that match autoloads. + # Hint entries carry no line — the hint is about an identifier, not a + # source position. + var hints := _check_autoload_hints(pre_id) + hints.append_array(_check_preload_hints(pre_id)) + for hint in hints: + diagnostics.append({ + "severity": "hint", + "message": hint, + }) + return MCPToolkitSuccess.ok({"valid": is_valid, "diagnostics": diagnostics}) + + +## Version-aware compile-error diagnostic message. editor_get_console surfaces editor +## PARSE errors only on Godot 4.5+ (the Logger API hooks the editor's error stream); on +## 4.2-4.4 file logging captures running-game output, not editor parse errors, so steering +## the LLM to editor_get_console there is a dead end — point to lsp_diagnostics (works 4.2+) +## instead. Both strings also steer the "did this change break ANOTHER script" case: on 4.5+ +## editor_refresh→console is a cheaper best-effort first pass (it reloads only changed+open +## scripts, so an unopened broken dependent can read falsely clean) and lsp_project_diagnostics +## is the guaranteed whole-project scan; on 4.2-4.4 lsp_project_diagnostics is the only +## project-wide compile signal (the console cannot surface project-wide parse errors pre-4.5). +## engine_ver is Modules.VersionUtils.get_engine_version_pair() (e.g. "4.5"). +static func _compile_error_message(engine_ver: String) -> String: + const CONSOLE := "GDScript compile error. Call editor_get_console for detailed messages with line numbers. To check whether the change broke another script, editor_refresh then editor_get_console(error) is a cheaper first pass (best-effort — misses unopened dependents); lsp_project_diagnostics is the guaranteed whole-project scan." + const LSP := "GDScript compile error. On Godot <4.5 editor parse errors are not surfaced by editor_get_console — call lsp_diagnostics for line-level detail (or read the script). To check whether the change broke another script, use lsp_project_diagnostics (the only project-wide compile signal on 4.2-4.4)." + return CONSOLE if Modules.VersionUtils.is_at_least(engine_ver, "4.5") else LSP + + +## Scan LogBuffer errors emitted during reload() for unresolved identifiers +## that match registered autoloads, returning actionable hint strings. +static func _check_autoload_hints(pre_id: int) -> Array: + var buf := Modules.LogBuffer.get_entries(50, ["error"], pre_id) + var entries: Array = buf.get("entries", []) + var seen := {} + var hints: Array = [] + for entry in entries: + var msg: String = str(entry.get("message", "")) + var m := _autoload_hint_re.search(msg) + if m == null: + continue + var ident: String = m.get_string(1) + if seen.has(ident): + continue + seen[ident] = true + if ProjectSettings.has_setting("autoload/" + ident): + hints.append( + "Identifier '%s' is a registered autoload. The editor cache may be stale — call autoload_manage with action='register' to refresh it, or reference via get_node('/root/%s')." % [ident, ident]) + else: + # Soft hint for PascalCase names that look like singletons. + if ident.length() >= 2 and ident[0] == ident[0].to_upper() and ident[0] != ident[0].to_lower(): + hints.append( + "Identifier '%s' not declared — if this is an autoload singleton, register it first with autoload_manage (action='register', name='%s', script_path='res://...')." % [ident, ident]) + return hints + + +## Scan LogBuffer errors for preload() failures referencing missing files. +static func _check_preload_hints(pre_id: int) -> Array: + var buf := Modules.LogBuffer.get_entries(50, ["error"], pre_id) + var entries: Array = buf.get("entries", []) + var seen := {} + var hints: Array = [] + for entry in entries: + var msg: String = str(entry.get("message", "")) + var m := _preload_hint_re.search(msg) + if m == null: + continue + var path: String = m.get_string(1) + if seen.has(path): + continue + seen[path] = true + if not FileAccess.file_exists(path): + hints.append( + "preload('%s') failed because the file doesn't exist yet. Use load() instead — it evaluates at runtime when the file will exist. Or create the file first, then use preload()." % path) + return hints + + +static func _write_file_raw(file_path: String, content: String) -> int: + var file := FileAccess.open(file_path, FileAccess.WRITE) + if file == null: + return FileAccess.get_open_error() + file.store_string(content) + file.close() + return OK diff --git a/addons/godot_mcp_toolkit/commands/script_commands.gd.uid b/addons/godot_mcp_toolkit/commands/script_commands.gd.uid new file mode 100644 index 0000000..d709fe2 --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/script_commands.gd.uid @@ -0,0 +1 @@ +uid://bgfwal5f76ll diff --git a/addons/godot_mcp_toolkit/commands/signal_commands.gd b/addons/godot_mcp_toolkit/commands/signal_commands.gd new file mode 100644 index 0000000..dc4e6fb --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/signal_commands.gd @@ -0,0 +1,284 @@ +@tool +extends RefCounted +## signal.* command handlers — list, manage (connect/disconnect), emit on edited-scene nodes. + +const Modules := preload("res://addons/godot_mcp_toolkit/core/modules.gd") +const Coerce = Modules.Coerce +const Helpers = Modules.CommandHelpers +const SignalPairResolver := preload("res://addons/godot_mcp_toolkit/scene/signal_pair_resolver.gd") + +static var _extends_path_re: RegEx = _compile_extends_path_re() + +static func _compile_extends_path_re() -> RegEx: + var re := RegEx.new() + re.compile('extends\\s+"(res://[^"]+)"') + return re + + +static func register(registry: MCPToolkitCommandRegistry, _server: Node) -> void: + registry.add("signal.list", func(parameters: Dictionary) -> Dictionary: + return _cmd_signal_list(parameters) + , MCPToolkitCommandOptions.new().mark_read_only()) + registry.add("signal.manage", func(parameters: Dictionary) -> Dictionary: + return _cmd_signal_manage(parameters) + , MCPToolkitCommandOptions.new()) + registry.add("signal.emit", func(parameters: Dictionary) -> Dictionary: + return _cmd_signal_emit(parameters) + , MCPToolkitCommandOptions.new()) + + +# -- Helpers ------------------------------------------------------------------ + + +static func _get_edited_root() -> Node: + return Helpers.get_edited_root() + + +static func _resolve_scene_node(node_path: String) -> Variant: + return Helpers.resolve_scene_node(node_path) + + +## Walk extends "res://..." directives via raw file reads when get_base_script() +## returns null (broken compilation chain). Returns true if any ancestor source +## contains the method definition. +static func _source_walk_has_method(source: String, method_name: String) -> bool: + var depth := 0 + while depth < 50: + var m := _extends_path_re.search(source) + if m == null: + return false + var parent_path := m.get_string(1) + if not FileAccess.file_exists(parent_path): + return false + source = FileAccess.get_file_as_string(parent_path) + if source.find("func %s" % method_name) >= 0: + return true + depth += 1 + return false + + +static func _signal_list_of( + node: Object, include_connections: bool = false, root: Node = null, +) -> Array: + var result: Array = [] + for signal_info in node.get_signal_list(): + var signal_name := str(signal_info.get("name", "")) + var arguments: Array = [] + for argument in signal_info.get("args", []): + arguments.append({ + "name": str(argument.get("name", "")), + "type": int(argument.get("type", 0)), + }) + var entry := { + "name": signal_name, + "args": arguments, + } + if include_connections: + var connections: Array = [] + for conn in node.get_signal_connection_list(signal_name): + var callable: Callable = conn.get("callable", Callable()) + var target_obj = callable.get_object() + var target_path := "" + if target_obj is Node and root != null: + if target_obj == root: + target_path = "." + else: + target_path = str(root.get_path_to(target_obj)) + elif target_obj != null: + target_path = str(target_obj.get_class()) + else: + target_path = "" + connections.append({ + "target_path": target_path, + "method_name": callable.get_method(), + "flags": int(conn.get("flags", 0)), + }) + entry["connections"] = connections + result.append(entry) + return result + + +## Editor wrapper over the shared SignalPairResolver. It normalizes the editor- +## relative paths, distinguishes the no-edited-scene case as NO_SCENE (the shared +## resolver only knows NOT_FOUND), delegates the structural validation to the shared +## resolver, then — on a has_signal/has_method failure — layers the editor-only hint +## enrichment (instanced-scene hint, script-source method-walk) the runtime path +## deliberately omits. The shared resolver owns the skeleton; this owns the hints. +static func _resolve_signal_pair(parameters: Variant) -> Dictionary: + if typeof(parameters) != TYPE_DICTIONARY: + return {"code": "INVALID_PARAMS", "error": "params must be an object"} + # Normalize /root/… paths to editor-relative form before resolving, and keep the + # original guard ORDER exactly: required-params first, then no-edited-scene + # (NO_SCENE, which the shared resolver can't distinguish from NOT_FOUND). + var source_path := Helpers.normalize_editor_path( + str(parameters.get("node_path", parameters.get("source_path", "")))) + var signal_name := str(parameters.get("signal_name", "")) + var target_path := Helpers.normalize_editor_path(str(parameters.get("target_path", ""))) + var method_name := str(parameters.get("method_name", "")) + if source_path.is_empty() or signal_name.is_empty() \ + or target_path.is_empty() or method_name.is_empty(): + return {"code": "INVALID_PARAMS", + "error": "node_path, signal_name, target_path, method_name are all required"} + var root := _get_edited_root() + if root == null: + return {"code": "NO_SCENE", "error": "no edited scene"} + # Delegate the structural validation (node resolution + has_signal/has_method) + # to the shared resolver with the already-normalized paths; it echoes those + # normalized paths in its NOT_FOUND messages, matching the pre-extraction output. + var normalized := { + "source_path": source_path, + "target_path": target_path, + "signal_name": signal_name, + "method_name": method_name, + } + var resolved := SignalPairResolver.resolve_pair(normalized, root) + if not resolved.has("error"): + return resolved + # Layer editor-only hints onto the two validation failures (signal/method + # missing). A NOT_FOUND (source/target missing) passes through unchanged. + if str(resolved.get("code", "")) != "INVALID_PARAMS": + return resolved + var source = _resolve_scene_node(source_path) + if source != null and not source.has_signal(signal_name): + var instance_hint := "" + var check: Node = source + while check != null and check != root: + if check.scene_file_path != "": + instance_hint = ". Node is instanced from %s — open that scene to connect script-defined signals" % check.scene_file_path + break + check = check.get_parent() + return {"code": "INVALID_PARAMS", + "error": "signal %s not on %s%s" % [signal_name, source_path, instance_hint]} + var target = _resolve_scene_node(target_path) + if target != null and not target.has_method(method_name): + var method_hint := "" + var scr := target.get_script() as Script + if scr == null: + method_hint = "; no script is attached — use node_set_script first, or connect via _ready() code" + elif scr is GDScript: + var found_in_source := false + var walk: GDScript = scr + while walk != null: + if walk.source_code.find("func %s" % method_name) >= 0: + found_in_source = true + break + var next := walk.get_base_script() as GDScript + if next == null: + # Compiled chain broke — walk remaining ancestors via source text + found_in_source = _source_walk_has_method( + walk.source_code, method_name) + break + walk = next + if found_in_source: + method_hint = "; method found in script source but not visible — the script (or a parent script) likely has compilation errors (check editor_get_console)" + else: + method_hint = "; script %s is attached but does not define this method — check spelling and inheritance chain" % scr.resource_path + return {"code": "INVALID_PARAMS", + "error": "method %s not on %s%s" % [method_name, target_path, method_hint]} + # INVALID_PARAMS with no scene node to hint on (e.g. missing params) — pass through. + return resolved + + +# -- Commands ----------------------------------------------------------------- + + +static func _cmd_signal_list(parameters: Dictionary) -> Dictionary: + var root := _get_edited_root() + if root == null: + return MCPToolkitError.fail("NO_SCENE", "no edited scene") + var node_path := str(parameters.get("node_path", "")) + node_path = Helpers.normalize_editor_path(node_path) + var include_connections: bool = bool(parameters.get("include_connections", false)) + var node = _resolve_scene_node(node_path) + if node == null: + return MCPToolkitError.fail("NOT_FOUND", "node not found: %s" % node_path, MCPToolkitError.HINT_NODE_PATH) + return MCPToolkitSuccess.ok({"path": node_path, "signals": _signal_list_of(node, include_connections, root)}) + + +static func _cmd_signal_manage(parameters: Dictionary) -> Dictionary: + var action := str(parameters.get("action", "")) + if not (action in ["connect", "disconnect"]): + return MCPToolkitError.fail("INVALID_PARAMS", + "action must be 'connect' or 'disconnect' (got '%s')" % action) + var resolved := _resolve_signal_pair(parameters) + if resolved.has("error"): + return MCPToolkitError.fail(str(resolved["code"]), str(resolved["error"])) + var source = resolved["source"] + var callable: Callable = resolved["callable"] + var signal_name: String = str(resolved["signal_name"]) + var source_path: String = str(resolved["source_path"]) + var target_path: String = str(resolved["target_path"]) + var method_name: String = str(resolved["method_name"]) + if action == "connect": + if source.is_connected(signal_name, callable): + return MCPToolkitSuccess.ok({ + "status": "returned", + "node_path": source_path, + "signal": signal_name, + "target_path": target_path, + "method": method_name, + }) + source.connect(signal_name, callable, Object.CONNECT_PERSIST) + MCPToolkitUndoRedoAction.begin("connect %s.%s -> %s.%s" % [ + source_path, signal_name, target_path, method_name], source) \ + .do_method(source.connect.bind(signal_name, callable, Object.CONNECT_PERSIST)) \ + .undo_method(source.disconnect.bind(signal_name, callable)) \ + .commit_recorded() + var response := MCPToolkitSuccess.ok({ + "status": "created", + "node_path": source_path, + "signal": signal_name, + "target_path": target_path, + "method": method_name, + }) + var target = resolved["target"] + var same_scene: bool = (source.owner == target.owner) \ + or (source == target.owner) or (target == source.owner) + if not same_scene: + response["hint"] = "Cross-scene connections (nodes from different .tscn files) cannot persist — use _ready() code instead." + else: + response["hint"] = "Save the scene to persist this connection." + return response + else: + if not source.is_connected(signal_name, callable): + return MCPToolkitError.fail("NOT_FOUND", "no connection to disconnect") + source.disconnect(signal_name, callable) + MCPToolkitUndoRedoAction.begin("disconnect %s.%s -> %s.%s" % [ + source_path, signal_name, target_path, method_name], source) \ + .do_method(source.disconnect.bind(signal_name, callable)) \ + .undo_method(source.connect.bind(signal_name, callable, Object.CONNECT_PERSIST)) \ + .commit_recorded() + return MCPToolkitSuccess.ok({ + "node_path": source_path, + "signal": signal_name, + "target_path": target_path, + "method": method_name, + }) + + +static func _cmd_signal_emit(parameters: Dictionary) -> Dictionary: + var root := _get_edited_root() + if root == null: + return MCPToolkitError.fail("NO_SCENE", "no edited scene") + var node_path := str(parameters.get("node_path", "")) + node_path = Helpers.normalize_editor_path(node_path) + var signal_name := str(parameters.get("signal_name", "")) + if signal_name.is_empty(): + return MCPToolkitError.fail("INVALID_PARAMS", "missing signal") + var node = _resolve_scene_node(node_path) + if node == null: + return MCPToolkitError.fail("NOT_FOUND", "node not found: %s" % node_path, MCPToolkitError.HINT_NODE_PATH) + if not node.has_signal(signal_name): + return MCPToolkitError.fail("INVALID_PARAMS", + "signal %s not on %s" % [signal_name, node_path]) + var raw_args = parameters.get("args", []) + if typeof(raw_args) != TYPE_ARRAY: + raw_args = [] + var coerced: Array = [signal_name] + for argument in raw_args: + var coerced_arg = Coerce.coerce_value(argument) + if typeof(coerced_arg) == TYPE_DICTIONARY and (coerced_arg as Dictionary).has("_coerce_error"): + return MCPToolkitError.fail("INVALID_PARAMS", str(coerced_arg["_coerce_error"])) + coerced.append(coerced_arg) + node.callv("emit_signal", coerced) + return MCPToolkitSuccess.ok() diff --git a/addons/godot_mcp_toolkit/commands/signal_commands.gd.uid b/addons/godot_mcp_toolkit/commands/signal_commands.gd.uid new file mode 100644 index 0000000..c12a4b6 --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/signal_commands.gd.uid @@ -0,0 +1 @@ +uid://b1b5cux7nn8gf diff --git a/addons/godot_mcp_toolkit/commands/sound_commands.gd b/addons/godot_mcp_toolkit/commands/sound_commands.gd new file mode 100644 index 0000000..f121f7a --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/sound_commands.gd @@ -0,0 +1,144 @@ +@tool +extends RefCounted +## sound.generate — procedural placeholder SFX (PCM → AudioStreamWAV → .wav). +## +## Closes the other half of the can't-produce-assets blindspot: the LLM can't +## synthesise audio (agents have reached for Python to fake it). This generates +## short sound effects — single-oscillator tones (sine/square/triangle/sawtooth) +## or noise, with an optional pitch sweep and a numeric de-click/decay envelope — +## as a mono 16-bit WAV. SFX only; music/melodies are out of scope. + +const Modules := preload("res://addons/godot_mcp_toolkit/core/modules.gd") +const Helpers = Modules.CommandHelpers + +const ALLOWED_EXTS := ["wav"] +const MIX_RATE := 44100 +const MAX_DURATION := 5.0 +const DEFAULT_DURATION := 0.3 +const DEFAULT_DECLICK := 0.003 # 3 ms auto fade to avoid start/stop clicks +const WAVEFORMS := ["sine", "square", "triangle", "sawtooth", "noise"] + + +static func register(registry: MCPToolkitCommandRegistry, _server: Node) -> void: + registry.add("sound.generate", func(parameters: Dictionary) -> Dictionary: + return await _cmd_generate(parameters) + , MCPToolkitCommandOptions.new() + .mark_scene_independent() + .guard_project_path("file_path") + .with_timeout_ms(40000) + .with_success_hint( + "Assign the stream: node_set_property on " + + "AudioStreamPlayer/AudioStreamPlayer2D/AudioStreamPlayer3D.stream.")) + + +static func _cmd_generate(parameters: Dictionary) -> Dictionary: + var file_path := str(parameters.get("file_path", "")) + if file_path == "": + return MCPToolkitError.fail("INVALID_PARAMS", + "file_path is required (res:// destination ending in .wav)", MCPToolkitError.HINT_FILE_PATH) + + var waveform := str(parameters.get("waveform", "sine")).to_lower() + if waveform not in WAVEFORMS: + return MCPToolkitError.fail("INVALID_PARAMS", + "waveform must be one of: %s (got '%s')" % [", ".join(WAVEFORMS), waveform]) + + var duration: float = clampf(float(parameters.get("duration", DEFAULT_DURATION)), 0.001, MAX_DURATION) + var frequency: float = clampf(float(parameters.get("frequency", 440.0)), 1.0, 20000.0) + var has_end := parameters.has("end_frequency") + var end_frequency: float = clampf(float(parameters.get("end_frequency", frequency)), 1.0, 20000.0) + var volume: float = clampf(float(parameters.get("volume", 0.8)), 0.0, 1.0) + var decay: float = maxf(0.0, float(parameters.get("decay", 0.0))) + + # Envelope fades (default ~3 ms de-click; clamped so in+out <= duration). + var fade_in: float = maxf(0.0, float(parameters.get("fade_in", DEFAULT_DECLICK))) + var fade_out: float = maxf(0.0, float(parameters.get("fade_out", DEFAULT_DECLICK))) + if fade_in + fade_out > duration: + var scale := duration / (fade_in + fade_out) + fade_in *= scale + fade_out *= scale + + var data := _build_pcm( + waveform, frequency, end_frequency, has_end, duration, volume, fade_in, fade_out, decay) + + var stream := AudioStreamWAV.new() + stream.format = AudioStreamWAV.FORMAT_16_BITS + stream.mix_rate = MIX_RATE + stream.stereo = false + stream.data = data + + var write_fn := func(write_path: String) -> Dictionary: + var err := stream.save_to_wav(write_path) + if err != OK: + return MCPToolkitError.fail("WRITE_FAILED", + "AudioStreamWAV.save_to_wav failed for %s (err %d)" % [write_path, err]) + return {} + + var if_exists := str(parameters.get("if_exists", "return")) + # Default 0 = no import-settle poll: a generated WAV is an AudioStreamWAV by + # construction, so the class is reported directly (no FS round-trip). Pass an + # explicit wait_for_scan_ms > 0 to force a settle. + var wait_for_scan_ms := int(parameters.get("wait_for_scan_ms", 0)) + var result := await Helpers.write_asset_with_settle( + file_path, PackedStringArray(ALLOWED_EXTS), if_exists, wait_for_scan_ms, "sound.generate", write_fn, "AudioStreamWAV") + if not result.get("success", false): + return result + + if result.get("status") != "returned": + result["waveform"] = waveform + result["duration"] = snappedf(duration, 0.001) + result["frequency"] = snappedf(frequency, 0.01) + if has_end: + result["end_frequency"] = snappedf(end_frequency, 0.01) + return result + + +## Synthesize mono 16-bit little-endian PCM. Pure (no editor) — unit-testable. +static func _build_pcm( + waveform: String, frequency: float, end_frequency: float, has_end: bool, + duration: float, volume: float, fade_in: float, fade_out: float, decay: float, +) -> PackedByteArray: + var sample_count := maxi(1, int(duration * MIX_RATE)) + var fade_in_samples := int(fade_in * MIX_RATE) + var fade_out_samples := int(fade_out * MIX_RATE) + + var data := PackedByteArray() + data.resize(sample_count * 2) + + var phase := 0.0 + for i in range(sample_count): + var t := float(i) / MIX_RATE + var inst_freq := frequency + if has_end: + inst_freq = frequency + (end_frequency - frequency) * (t / duration) + + var sample := _oscillator(waveform, phase) + phase += TAU * inst_freq / MIX_RATE + + # Envelope: volume * fade * decay. + var env := volume + if fade_in_samples > 0 and i < fade_in_samples: + env *= float(i) / fade_in_samples + if fade_out_samples > 0 and i >= sample_count - fade_out_samples: + env *= float(sample_count - i) / fade_out_samples + if decay > 0.0: + env *= exp(-t / decay) + + var value := int(clampf(sample * env, -1.0, 1.0) * 32767.0) + data.encode_s16(i * 2, value) + return data + + +## One oscillator sample in [-1, 1] for the given accumulated phase (radians). +static func _oscillator(waveform: String, phase: float) -> float: + match waveform: + "sine": + return sin(phase) + "square": + return 1.0 if sin(phase) >= 0.0 else -1.0 + "triangle": + return 2.0 / PI * asin(sin(phase)) + "sawtooth": + return 2.0 * (fposmod(phase, TAU) / TAU) - 1.0 + "noise": + return randf_range(-1.0, 1.0) + return 0.0 diff --git a/addons/godot_mcp_toolkit/commands/sound_commands.gd.uid b/addons/godot_mcp_toolkit/commands/sound_commands.gd.uid new file mode 100644 index 0000000..116b3b8 --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/sound_commands.gd.uid @@ -0,0 +1 @@ +uid://bqqta3evvrwpq diff --git a/addons/godot_mcp_toolkit/commands/spatial_commands.gd b/addons/godot_mcp_toolkit/commands/spatial_commands.gd new file mode 100644 index 0000000..d086796 --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/spatial_commands.gd @@ -0,0 +1,329 @@ +@tool +extends RefCounted +## scene.spatial_map — read-only 2D/3D spatial layout of the current edited scene. +## +## Closes the LLM spatial-reasoning blindspot: per-node world position, bounds +## (2D Rect2 / 3D AABB), size, and the computed relations LLMs can't easily +## derive blind — pairwise overlaps, containment, and nearest-neighbour gaps. +## Read-only and tab-dependent (operates on EditorInterface.get_edited_scene_root). +## +## Published under the `scene.*` prefix (`scene.spatial_map`) because it reads +## the currently edited scene — it belongs beside `scene.create`/`scene.delete`, +## not in a standalone "spatial" namespace. The file noun "spatial" is internal +## vocabulary; the scene-read is the contract. + +const Modules := preload("res://addons/godot_mcp_toolkit/core/modules.gd") +const Helpers = Modules.CommandHelpers + +## Hard cap on nodes returned (also bounds the O(n^2) relation pass). +const DEFAULT_MAX_NODES := 200 +const HARD_MAX_NODES := 1000 + + +static func register(registry: MCPToolkitCommandRegistry, _server: Node) -> void: + registry.add("scene.spatial_map", func(parameters: Dictionary) -> Dictionary: + return _cmd_spatial_map(parameters) + , MCPToolkitCommandOptions.new() + .mark_read_only() + .with_success_hint( + "Reason about clear positions from the overlaps/gaps, then place or move " + + "nodes with node_set_property (e.g. position / global_position).")) + + +static func _cmd_spatial_map(parameters: Dictionary) -> Dictionary: + var root := Helpers.get_edited_root() + if root == null: + return MCPToolkitError.fail("NO_SCENE", + "no scene is open in the editor; open a scene tab first (scene.open) before mapping its layout") + + var detail := str(parameters.get("detail", "normal")).to_lower() + if detail not in ["brief", "normal", "full"]: + return MCPToolkitError.fail("INVALID_PARAMS", + "detail must be one of 'brief', 'normal', 'full' (got '%s')" % detail) + + var class_filter := str(parameters.get("class", "")) + var subtree_path := str(parameters.get("subtree", "")) + var max_nodes := int(parameters.get("max_nodes", DEFAULT_MAX_NODES)) + if max_nodes <= 0 or max_nodes > HARD_MAX_NODES: + max_nodes = DEFAULT_MAX_NODES + + # Optional region / radius filters (dimensionality decides 2D vs 3D scope). + var region_filter = _parse_region(parameters.get("region", null)) + if typeof(region_filter) == TYPE_DICTIONARY and region_filter.has("error"): + return region_filter + var radius_filter = _parse_radius(parameters) + if typeof(radius_filter) == TYPE_DICTIONARY and radius_filter.has("error"): + return radius_filter + + # Resolve the walk root (whole scene, or a subtree). + var walk_root: Node = root + if subtree_path != "" and subtree_path != ".": + walk_root = root.get_node_or_null(subtree_path) + if walk_root == null: + return MCPToolkitError.fail("NOT_FOUND", + "subtree node not found: %s" % subtree_path, MCPToolkitError.HINT_NODE_PATH) + + # Gather spatial entries. + var entries: Array = [] + var total_spatial := 0 + for node in _iter_tree(walk_root): + if class_filter != "" and not _node_is_class(node, class_filter): + continue + var bounds = _world_bounds(node) + if bounds == null: + continue # non-spatial node (plain Node, resources, etc.) + if not _passes_filters(bounds, region_filter, radius_filter): + continue + total_spatial += 1 + if entries.size() >= max_nodes: + continue # keep counting total, stop collecting + entries.append({ + "node": node, + "path": _rel_path(root, node), + "class": node.get_class(), + "bounds": bounds, + }) + + # Compute relations (skipped for brief). + if detail != "brief": + _compute_relations(entries, detail) + + # Serialize. + var nodes_out: Array = [] + var count_2d := 0 + var count_3d := 0 + for e in entries: + var bounds = e["bounds"] + var is_2d := typeof(bounds) == TYPE_RECT2 + if is_2d: + count_2d += 1 + else: + count_3d += 1 + var row := { + "path": e["path"], + "class": e["class"], + "space": "2d" if is_2d else "3d", + "position": _vec_to_array(bounds.position), + "size": _vec_to_array(bounds.size), + } + if detail != "brief": + row["bounds"] = { + "position": _vec_to_array(bounds.position), + "size": _vec_to_array(bounds.size), + } + row["overlaps"] = e.get("overlaps", []) + if detail == "full": + row["contains"] = e.get("contains", []) + row["contained_by"] = e.get("contained_by", []) + if e.has("nearest"): + row["nearest"] = e["nearest"] + nodes_out.append(row) + + var space := "empty" + if count_2d > 0 and count_3d > 0: + space = "mixed" + elif count_2d > 0: + space = "2d" + elif count_3d > 0: + space = "3d" + + # total_nodes: full match count (counted past the cap); cursor-less — narrow + # with filters or raise max_nodes to resume. + var has_more := total_spatial > nodes_out.size() + var hint := "" + if has_more: + hint = ("%d of %d spatial nodes returned (capped at max_nodes) — narrow with " + + "subtree / class / region / radius, or raise max_nodes (<= %d).") % [ + nodes_out.size(), total_spatial, HARD_MAX_NODES] + var result := Modules.Pagination.build( + {"space": space, "detail": detail, "nodes": nodes_out}, + "nodes", total_spatial, nodes_out.size(), has_more, "", 0, hint) + return MCPToolkitSuccess.ok(result) + + +# -- Geometry ----------------------------------------------------------------- + + +## World-space bounds for a node: Rect2 (2D) or AABB (3D), or null if the node +## has no spatial representation. +static func _world_bounds(node: Node) -> Variant: + if node is Control: + return (node as Control).get_global_rect() + if node is Node2D: + var n2d := node as Node2D + if n2d.has_method("get_rect"): + var local_rect: Rect2 = n2d.call("get_rect") + return _xform_rect2(n2d.get_global_transform(), local_rect) + return Rect2(n2d.global_position, Vector2.ZERO) + if node is VisualInstance3D: + var vi := node as VisualInstance3D + return _xform_aabb(vi.global_transform, vi.get_aabb()) + if node is Node3D: + return AABB((node as Node3D).global_position, Vector3.ZERO) + return null + + +## Transform a local Rect2 by a Transform2D into a world-space AABB (4 corners). +static func _xform_rect2(xform: Transform2D, rect: Rect2) -> Rect2: + var p0 := xform * rect.position + var corners := [ + p0, + xform * (rect.position + Vector2(rect.size.x, 0)), + xform * (rect.position + Vector2(0, rect.size.y)), + xform * (rect.position + rect.size), + ] + var out := Rect2(p0, Vector2.ZERO) + for c in corners: + out = out.expand(c) + return out + + +## Transform a local AABB by a Transform3D into a world-space AABB (8 corners). +static func _xform_aabb(xform: Transform3D, aabb: AABB) -> AABB: + var p0: Vector3 = xform * aabb.position + var out := AABB(p0, Vector3.ZERO) + for i in range(8): + var corner := aabb.position + Vector3( + aabb.size.x if (i & 1) else 0.0, + aabb.size.y if (i & 2) else 0.0, + aabb.size.z if (i & 4) else 0.0) + out = out.expand(xform * corner) + return out + + +## Pairwise overlap / containment / nearest-neighbour, same-space only. +static func _compute_relations(entries: Array, detail: String) -> void: + for e in entries: + e["overlaps"] = [] + if detail == "full": + e["contains"] = [] + e["contained_by"] = [] + var n := entries.size() + var nearest_dist := [] + nearest_dist.resize(n) + nearest_dist.fill(INF) + for i in range(n): + var bi = entries[i]["bounds"] + var i_is_2d := typeof(bi) == TYPE_RECT2 + for j in range(i + 1, n): + var bj = entries[j]["bounds"] + if (typeof(bj) == TYPE_RECT2) != i_is_2d: + continue # never relate across 2D/3D + if bi.intersects(bj): + entries[i]["overlaps"].append(entries[j]["path"]) + entries[j]["overlaps"].append(entries[i]["path"]) + if detail == "full": + if bi.encloses(bj): + entries[i]["contains"].append(entries[j]["path"]) + entries[j]["contained_by"].append(entries[i]["path"]) + elif bj.encloses(bi): + entries[j]["contains"].append(entries[i]["path"]) + entries[i]["contained_by"].append(entries[j]["path"]) + var d: float = bi.get_center().distance_to(bj.get_center()) + if d < nearest_dist[i]: + nearest_dist[i] = d + entries[i]["nearest"] = {"path": entries[j]["path"], "distance": snappedf(d, 0.001)} + if d < nearest_dist[j]: + nearest_dist[j] = d + entries[j]["nearest"] = {"path": entries[i]["path"], "distance": snappedf(d, 0.001)} + + +# -- Filters ------------------------------------------------------------------ + + +## Parse an optional region filter: [x,y,w,h] (2D) or [x,y,z,sx,sy,sz] (3D). +## Returns null (no filter), a Rect2, an AABB, or an {error} dict. +static func _parse_region(raw: Variant) -> Variant: + if raw == null: + return null + if typeof(raw) != TYPE_ARRAY: + return MCPToolkitError.fail("INVALID_PARAMS", + "region must be an array [x,y,w,h] (2D) or [x,y,z,sx,sy,sz] (3D)") + var a := raw as Array + if a.size() == 4: + return Rect2(float(a[0]), float(a[1]), float(a[2]), float(a[3])) + if a.size() == 6: + return AABB(Vector3(a[0], a[1], a[2]), Vector3(a[3], a[4], a[5])) + return MCPToolkitError.fail("INVALID_PARAMS", + "region must have 4 numbers (2D [x,y,w,h]) or 6 (3D [x,y,z,sx,sy,sz]); got %d" % a.size()) + + +## Parse an optional radius+center filter. Returns null or {center, radius, is_2d} +## or an {error} dict. +static func _parse_radius(parameters: Dictionary) -> Variant: + if not parameters.has("radius"): + return null + var radius := float(parameters.get("radius", 0.0)) + if radius <= 0.0: + return MCPToolkitError.fail("INVALID_PARAMS", "radius must be > 0") + var center_raw = parameters.get("center", null) + if typeof(center_raw) != TYPE_ARRAY: + return MCPToolkitError.fail("INVALID_PARAMS", + "radius requires center: [x,y] (2D) or [x,y,z] (3D)") + var c := center_raw as Array + if c.size() == 2: + return {"center": Vector2(c[0], c[1]), "radius": radius, "is_2d": true} + if c.size() == 3: + return {"center": Vector3(c[0], c[1], c[2]), "radius": radius, "is_2d": false} + return MCPToolkitError.fail("INVALID_PARAMS", + "center must have 2 numbers (2D) or 3 (3D); got %d" % c.size()) + + +static func _passes_filters(bounds: Variant, region: Variant, radius: Variant) -> bool: + var is_2d := typeof(bounds) == TYPE_RECT2 + if region != null: + var region_is_2d := typeof(region) == TYPE_RECT2 + if region_is_2d != is_2d: + return false + if not bounds.intersects(region): + return false + if radius != null: + if (radius["is_2d"] as bool) != is_2d: + return false + if bounds.get_center().distance_to(radius["center"]) > (radius["radius"] as float): + return false + return true + + +# -- Tree / class / serialization helpers ------------------------------------- + + +## Depth-first iteration over a node and all its descendants. +static func _iter_tree(node: Node) -> Array: + var out: Array = [node] + for child in node.get_children(): + out.append_array(_iter_tree(child)) + return out + + +static func _node_is_class(node: Node, type_name: String) -> bool: + if node.get_class() == type_name: + return true + if node.is_class(type_name): + return true + # Custom (class_name) scripts — match the script's resource_path against the + # global class list. Script.get_global_name() is 4.3+, so this stays 4.2-safe + # (same pattern as editor_helpers.class_descends_from). + var script := node.get_script() + if script != null and script is Script: + var script_path := (script as Script).resource_path + if script_path != "": + for entry in ProjectSettings.get_global_class_list(): + if String(entry.get("path", "")) == script_path: + return String(entry.get("class", "")) == type_name + return false + + +static func _rel_path(root: Node, node: Node) -> String: + if node == root: + return "." + return "./" + String(root.get_path_to(node)) + + +static func _vec_to_array(v: Variant) -> Array: + match typeof(v): + TYPE_VECTOR2: + return [snappedf(v.x, 0.001), snappedf(v.y, 0.001)] + TYPE_VECTOR3: + return [snappedf(v.x, 0.001), snappedf(v.y, 0.001), snappedf(v.z, 0.001)] + return [] diff --git a/addons/godot_mcp_toolkit/commands/spatial_commands.gd.uid b/addons/godot_mcp_toolkit/commands/spatial_commands.gd.uid new file mode 100644 index 0000000..7c9a238 --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/spatial_commands.gd.uid @@ -0,0 +1 @@ +uid://c2hc3avf0dwus diff --git a/addons/godot_mcp_toolkit/commands/spriteframes_commands.gd b/addons/godot_mcp_toolkit/commands/spriteframes_commands.gd new file mode 100644 index 0000000..462c81b --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/spriteframes_commands.gd @@ -0,0 +1,371 @@ +@tool +extends RefCounted +## spriteframes.* command handlers — SpriteFrames resource creation and editing. + +const Modules := preload("res://addons/godot_mcp_toolkit/core/modules.gd") +const FileGuard = Modules.FileGuard +const Helpers = Modules.CommandHelpers + + +static func register(registry: MCPToolkitCommandRegistry, _server: Node) -> void: + registry.add("spriteframes.create", func(p: Dictionary) -> Dictionary: + return _cmd_create(p) + , MCPToolkitCommandOptions.new().mark_scene_independent()) + registry.add("spriteframes.edit", func(p: Dictionary) -> Dictionary: + return _cmd_edit(p) + , MCPToolkitCommandOptions.new().mark_scene_independent()) + registry.add("spriteframes.from_spritesheet", func(p: Dictionary) -> Dictionary: + return _cmd_from_spritesheet(p) + , MCPToolkitCommandOptions.new().mark_scene_independent()) + + +# -- Commands ----------------------------------------------------------------- + + +static func _cmd_create(parameters: Dictionary) -> Dictionary: + var err = MCPToolkitError.require(parameters, ["file_path", "animations"]) + if err != null: + return err + + var file_path := str(parameters.get("file_path", "")) + file_path = Helpers.normalize_editor_path(file_path) + var animations_raw = parameters.get("animations", null) + + if typeof(animations_raw) != TYPE_ARRAY or animations_raw.is_empty(): + return MCPToolkitError.fail("INVALID_PARAMS", "animations must be a non-empty Array") + + var guard := FileGuard.resolve_safe(file_path) + if guard["error"] != null: + return MCPToolkitError.fail("PATH_DENIED", str(guard["reason"])) + if file_path.get_extension().to_lower() != "tres": + return MCPToolkitError.fail("INVALID_PATH", + "spriteframes.create writes .tres files (got %s)" % file_path) + + # Idempotency: if file already exists, return early. + if FileAccess.file_exists(file_path): + return MCPToolkitSuccess.ok({"status": "returned", "path": file_path}) + + var sf := SpriteFrames.new() + var anim_info := [] + + for anim_raw in animations_raw: + if typeof(anim_raw) != TYPE_DICTIONARY: + return MCPToolkitError.fail("INVALID_PARAMS", "each animation must be a Dictionary") + + var anim_name := str(anim_raw.get("name", "")) + if anim_name.is_empty(): + return MCPToolkitError.fail("INVALID_PARAMS", "animation name is required") + + var fps: float = float(anim_raw.get("fps", 5.0)) + var loop: bool = bool(anim_raw.get("loop", true)) + var frames_raw = anim_raw.get("frames", []) + + sf.add_animation(anim_name) + sf.set_animation_speed(anim_name, fps) + sf.set_animation_loop(anim_name, loop) + + if typeof(frames_raw) == TYPE_ARRAY: + for frame_raw in frames_raw: + var result = _load_frame(frame_raw) + if result.has("error"): + return result + sf.add_frame(anim_name, result["texture"], result["duration"]) + + anim_info.append({ + "name": anim_name, + "frame_count": sf.get_frame_count(anim_name), + "fps": fps, + "loop": loop, + }) + + # Remove the auto-created "default" animation when custom ones provided. + if sf.has_animation("default") and anim_info.size() > 0: + sf.remove_animation("default") + + var dir_path := file_path.get_base_dir() + if not DirAccess.dir_exists_absolute(dir_path): + DirAccess.make_dir_recursive_absolute(dir_path) + + var save_err := ResourceSaver.save(sf, file_path) + if save_err != OK: + return MCPToolkitError.fail("SAVE_FAILED", + "ResourceSaver.save() returned error %d for %s" % [save_err, file_path]) + + return MCPToolkitSuccess.ok({"path": file_path, "status": "created", "animations": anim_info}) + + +static func _cmd_edit(parameters: Dictionary) -> Dictionary: + var err = MCPToolkitError.require(parameters, ["file_path", "action"]) + if err != null: + return err + + var file_path := str(parameters.get("file_path", "")) + file_path = Helpers.normalize_editor_path(file_path) + var action := str(parameters.get("action", "")) + + var guard := FileGuard.resolve_safe(file_path) + if guard["error"] != null: + return MCPToolkitError.fail("PATH_DENIED", str(guard["reason"])) + + if not FileAccess.file_exists(file_path): + return MCPToolkitError.fail("NOT_FOUND", "SpriteFrames resource not found: %s" % file_path) + + var loaded = ResourceLoader.load(file_path, "", ResourceLoader.CACHE_MODE_IGNORE) + if loaded == null or not (loaded is SpriteFrames): + return MCPToolkitError.fail("INVALID_CLASS", + "Resource at %s is not a SpriteFrames" % file_path) + var sf: SpriteFrames = loaded as SpriteFrames + + var anim_name := str(parameters.get("animation_name", "")) + + match action: + "list": + return _build_list(sf) + + "add_animation": + if anim_name.is_empty(): + return MCPToolkitError.fail("INVALID_PARAMS", "animation_name is required") + if sf.has_animation(anim_name): + return MCPToolkitSuccess.ok({"action": action, "animation_name": anim_name, "status": "returned"}) + sf.add_animation(anim_name) + var fps_raw = parameters.get("fps", null) + if fps_raw != null: + sf.set_animation_speed(anim_name, float(fps_raw)) + var loop_raw = parameters.get("loop", null) + if loop_raw != null: + sf.set_animation_loop(anim_name, bool(loop_raw)) + + "remove_animation": + if anim_name.is_empty(): + return MCPToolkitError.fail("INVALID_PARAMS", "animation_name is required") + if not sf.has_animation(anim_name): + return MCPToolkitError.fail("NOT_FOUND", "Animation '%s' not found" % anim_name) + sf.remove_animation(anim_name) + + "add_frame": + if anim_name.is_empty(): + return MCPToolkitError.fail("INVALID_PARAMS", "animation_name is required") + if not sf.has_animation(anim_name): + return MCPToolkitError.fail("NOT_FOUND", "Animation '%s' not found" % anim_name) + var frames_raw = parameters.get("frames", null) + if frames_raw == null or typeof(frames_raw) != TYPE_ARRAY or frames_raw.is_empty(): + return MCPToolkitError.fail("INVALID_PARAMS", "frames array is required for add_frame") + for frame_raw in frames_raw: + var result = _load_frame(frame_raw) + if result.has("error"): + return result + sf.add_frame(anim_name, result["texture"], result["duration"]) + + "remove_frame": + if anim_name.is_empty(): + return MCPToolkitError.fail("INVALID_PARAMS", "animation_name is required") + if not sf.has_animation(anim_name): + return MCPToolkitError.fail("NOT_FOUND", "Animation '%s' not found" % anim_name) + var frame_index_raw = parameters.get("frame_index", null) + if frame_index_raw == null: + return MCPToolkitError.fail("INVALID_PARAMS", "frame_index is required for remove_frame") + var idx := int(frame_index_raw) + if idx < 0 or idx >= sf.get_frame_count(anim_name): + return MCPToolkitError.fail("INVALID_PARAMS", + "frame_index %d out of range (0..%d)" % [idx, sf.get_frame_count(anim_name) - 1]) + sf.remove_frame(anim_name, idx) + + "set_fps": + if anim_name.is_empty(): + return MCPToolkitError.fail("INVALID_PARAMS", "animation_name is required") + if not sf.has_animation(anim_name): + return MCPToolkitError.fail("NOT_FOUND", "Animation '%s' not found" % anim_name) + var fps_val = parameters.get("fps", null) + if fps_val == null: + return MCPToolkitError.fail("INVALID_PARAMS", "fps is required for set_fps") + sf.set_animation_speed(anim_name, float(fps_val)) + + "set_loop": + if anim_name.is_empty(): + return MCPToolkitError.fail("INVALID_PARAMS", "animation_name is required") + if not sf.has_animation(anim_name): + return MCPToolkitError.fail("NOT_FOUND", "Animation '%s' not found" % anim_name) + var loop_val = parameters.get("loop", null) + if loop_val == null: + return MCPToolkitError.fail("INVALID_PARAMS", "loop is required for set_loop") + sf.set_animation_loop(anim_name, bool(loop_val)) + + "reorder_frames": + if anim_name.is_empty(): + return MCPToolkitError.fail("INVALID_PARAMS", "animation_name is required") + if not sf.has_animation(anim_name): + return MCPToolkitError.fail("NOT_FOUND", "Animation '%s' not found" % anim_name) + var from_idx_raw = parameters.get("frame_index", null) + var to_idx_raw = parameters.get("new_index", null) + if from_idx_raw == null or to_idx_raw == null: + return MCPToolkitError.fail("INVALID_PARAMS", "frame_index and new_index are required for reorder_frames") + var from_idx := int(from_idx_raw) + var to_idx := int(to_idx_raw) + var count := sf.get_frame_count(anim_name) + if from_idx < 0 or from_idx >= count or to_idx < 0 or to_idx >= count: + return MCPToolkitError.fail("INVALID_PARAMS", + "frame_index/new_index out of range (0..%d)" % [count - 1]) + var tex = sf.get_frame_texture(anim_name, from_idx) + var dur := sf.get_frame_duration(anim_name, from_idx) + sf.remove_frame(anim_name, from_idx) + sf.add_frame(anim_name, tex, dur, to_idx) + + _: + return MCPToolkitError.fail("INVALID_PARAMS", + "Unknown action '%s'. Expected: add_animation, remove_animation, add_frame, remove_frame, set_fps, set_loop, reorder_frames, list" % action) + + var save_err := ResourceSaver.save(sf, file_path) + if save_err != OK: + return MCPToolkitError.fail("SAVE_FAILED", + "ResourceSaver.save() returned error %d for %s" % [save_err, file_path]) + + return MCPToolkitSuccess.ok({"action": action, "animation_name": anim_name}) + + +static func _cmd_from_spritesheet(parameters: Dictionary) -> Dictionary: + var err = MCPToolkitError.require(parameters, ["file_path", "texture_path", "frame_size", "animations"]) + if err != null: + return err + + var file_path := str(parameters.get("file_path", "")) + file_path = Helpers.normalize_editor_path(file_path) + var texture_path := str(parameters.get("texture_path", "")) + texture_path = Helpers.normalize_editor_path(texture_path) + var frame_size_raw = parameters.get("frame_size", null) + var animations_raw = parameters.get("animations", null) + + if typeof(frame_size_raw) != TYPE_DICTIONARY: + return MCPToolkitError.fail("INVALID_PARAMS", "frame_size must be {x, y}") + var frame_w := int(frame_size_raw.get("x", 0)) + var frame_h := int(frame_size_raw.get("y", 0)) + if frame_w <= 0 or frame_h <= 0: + return MCPToolkitError.fail("INVALID_PARAMS", "frame_size x and y must be positive integers") + + if typeof(animations_raw) != TYPE_ARRAY or animations_raw.is_empty(): + return MCPToolkitError.fail("INVALID_PARAMS", "animations must be a non-empty Array") + + var guard := FileGuard.resolve_safe(file_path) + if guard["error"] != null: + return MCPToolkitError.fail("PATH_DENIED", str(guard["reason"])) + if file_path.get_extension().to_lower() != "tres": + return MCPToolkitError.fail("INVALID_PATH", + "spriteframes.from_spritesheet writes .tres files (got %s)" % file_path) + + # Idempotency. + if FileAccess.file_exists(file_path): + return MCPToolkitSuccess.ok({"status": "returned", "path": file_path}) + + if not ResourceLoader.exists(texture_path): + return MCPToolkitError.fail("NOT_FOUND", "Spritesheet texture not found: %s" % texture_path) + var sheet_tex = ResourceLoader.load(texture_path) + if sheet_tex == null or not (sheet_tex is Texture2D): + return MCPToolkitError.fail("NOT_FOUND", "Failed to load spritesheet texture: %s" % texture_path) + + var tex_size: Vector2 = sheet_tex.get_size() + var cols := int(tex_size.x) / frame_w + var rows := int(tex_size.y) / frame_h + + var sf := SpriteFrames.new() + var anim_info := [] + + for anim_raw in animations_raw: + if typeof(anim_raw) != TYPE_DICTIONARY: + return MCPToolkitError.fail("INVALID_PARAMS", "each animation must be a Dictionary") + + var anim_name := str(anim_raw.get("name", "")) + if anim_name.is_empty(): + return MCPToolkitError.fail("INVALID_PARAMS", "animation name is required") + + var row := int(anim_raw.get("row", 0)) + var start_col := int(anim_raw.get("start_col", 0)) + var frame_count := int(anim_raw.get("frame_count", 0)) + if frame_count <= 0: + return MCPToolkitError.fail("INVALID_PARAMS", + "frame_count must be positive for animation '%s'" % anim_name) + + if row < 0 or row >= rows: + return MCPToolkitError.fail("INVALID_PARAMS", + "row %d out of bounds (texture has %d rows of %dpx)" % [row, rows, frame_h]) + if start_col < 0 or (start_col + frame_count) > cols: + return MCPToolkitError.fail("INVALID_PARAMS", + "columns %d..%d out of bounds (texture has %d cols of %dpx)" % [ + start_col, start_col + frame_count - 1, cols, frame_w]) + + var fps: float = float(anim_raw.get("fps", 5.0)) + var loop: bool = bool(anim_raw.get("loop", true)) + + sf.add_animation(anim_name) + sf.set_animation_speed(anim_name, fps) + sf.set_animation_loop(anim_name, loop) + + for col_idx in range(start_col, start_col + frame_count): + var atlas := AtlasTexture.new() + atlas.atlas = sheet_tex + atlas.region = Rect2(col_idx * frame_w, row * frame_h, frame_w, frame_h) + sf.add_frame(anim_name, atlas) + + anim_info.append({ + "name": anim_name, + "frame_count": frame_count, + "fps": fps, + "loop": loop, + }) + + if sf.has_animation("default") and anim_info.size() > 0: + sf.remove_animation("default") + + var dir_path := file_path.get_base_dir() + if not DirAccess.dir_exists_absolute(dir_path): + DirAccess.make_dir_recursive_absolute(dir_path) + + var save_err := ResourceSaver.save(sf, file_path) + if save_err != OK: + return MCPToolkitError.fail("SAVE_FAILED", + "ResourceSaver.save() returned error %d for %s" % [save_err, file_path]) + + return MCPToolkitSuccess.ok({"path": file_path, "status": "created", "animations": anim_info}) + + +# -- Helpers ------------------------------------------------------------------ + + +static func _load_frame(frame_raw) -> Dictionary: + if typeof(frame_raw) != TYPE_DICTIONARY: + return MCPToolkitError.fail("INVALID_PARAMS", "each frame must be a Dictionary") + + var texture_path := str(frame_raw.get("texture_path", "")) + if texture_path.is_empty(): + return MCPToolkitError.fail("INVALID_PARAMS", "frame texture path is required") + + if not ResourceLoader.exists(texture_path): + return MCPToolkitError.fail("NOT_FOUND", "Texture not found: %s" % texture_path) + + var tex = ResourceLoader.load(texture_path) + if tex == null or not (tex is Texture2D): + return MCPToolkitError.fail("NOT_FOUND", "Failed to load texture: %s" % texture_path) + + var atlas_raw = frame_raw.get("atlas", null) + if atlas_raw != null and typeof(atlas_raw) == TYPE_DICTIONARY: + var atlas := AtlasTexture.new() + atlas.atlas = tex + atlas.region = Rect2( + float(atlas_raw.get("x", 0)), + float(atlas_raw.get("y", 0)), + float(atlas_raw.get("width", 0)), + float(atlas_raw.get("height", 0))) + tex = atlas + + var duration: float = float(frame_raw.get("duration", 1.0)) + return {"texture": tex, "duration": duration} + + +static func _build_list(sf: SpriteFrames) -> Dictionary: + var anims := [] + for anim_name in sf.get_animation_names(): + anims.append({ + "name": str(anim_name), + "frame_count": sf.get_frame_count(anim_name), + "fps": sf.get_animation_speed(anim_name), + "loop": sf.get_animation_loop(anim_name), + }) + return MCPToolkitSuccess.ok({"animations": anims}) diff --git a/addons/godot_mcp_toolkit/commands/spriteframes_commands.gd.uid b/addons/godot_mcp_toolkit/commands/spriteframes_commands.gd.uid new file mode 100644 index 0000000..a609486 --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/spriteframes_commands.gd.uid @@ -0,0 +1 @@ +uid://4qgc3fn0tuof diff --git a/addons/godot_mcp_toolkit/commands/texture_commands.gd b/addons/godot_mcp_toolkit/commands/texture_commands.gd new file mode 100644 index 0000000..4021bf5 --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/texture_commands.gd @@ -0,0 +1,289 @@ +@tool +extends RefCounted +## texture.generate — procedural 2D placeholder textures (PNG → Texture2D). +## +## Closes half of the can't-produce-assets blindspot: the LLM can't draw, so +## prototypes are hand-built coloured rects. This synthesises distinguishable +## placeholder sprites — shapes/patterns with a 3-layer colour model +## (fill / outline / background, alpha-absent = layer omitted) plus an optional +## text label overlay — and writes a PNG that imports as a Texture2D. + +const Modules := preload("res://addons/godot_mcp_toolkit/core/modules.gd") +const Helpers = Modules.CommandHelpers + +const ALLOWED_EXTS := ["png"] +const MAX_DIM := 1024 +const DEFAULT_DIM := 64 +const SHAPES := ["solid", "circle", "triangle", "diamond", "arrow", "checkerboard", "grid"] +const DIRECTIONS := ["up", "down", "left", "right"] + + +static func register(registry: MCPToolkitCommandRegistry, _server: Node) -> void: + registry.add("texture.generate", func(parameters: Dictionary) -> Dictionary: + return await _cmd_generate(parameters) + , MCPToolkitCommandOptions.new() + .mark_scene_independent() + .guard_project_path("file_path") + .with_timeout_ms(40000) + .with_success_hint( + "Assign the texture: node_set_property on Sprite2D.texture / " + + "TextureRect.texture / Button.icon, or feed spriteframes_create.")) + + +static func _cmd_generate(parameters: Dictionary) -> Dictionary: + var file_path := str(parameters.get("file_path", "")) + if file_path == "": + return MCPToolkitError.fail("INVALID_PARAMS", + "file_path is required (res:// destination ending in .png)", MCPToolkitError.HINT_FILE_PATH) + + var shape := str(parameters.get("shape", "solid")).to_lower() + if shape not in SHAPES: + return MCPToolkitError.fail("INVALID_PARAMS", + "shape must be one of: %s (got '%s')" % [", ".join(SHAPES), shape]) + + var width: int = clampi(int(parameters.get("width", DEFAULT_DIM)), 1, MAX_DIM) + var height: int = clampi(int(parameters.get("height", DEFAULT_DIM)), 1, MAX_DIM) + + var fill_color := _parse_color(parameters.get("fill_color", null), Color(0.8, 0.8, 0.8, 1.0)) + var outline_color := _parse_color(parameters.get("outline_color", null), Color(0, 0, 0, 0)) + var background_color := _parse_color(parameters.get("background_color", null), Color(0, 0, 0, 0)) + var outline_width: int = maxi(0, int(parameters.get("outline_width", 1))) + + # Blank-result guard: at least one layer must be visible. + if fill_color.a <= 0.0 and outline_color.a <= 0.0 and background_color.a <= 0.0: + return MCPToolkitError.fail("INVALID_PARAMS", + "result would be fully transparent — set fill_color, outline_color, or background_color (alpha > 0)") + + var direction := str(parameters.get("direction", "right")).to_lower() + if direction not in DIRECTIONS: + return MCPToolkitError.fail("INVALID_PARAMS", + "direction must be one of: %s (got '%s')" % [", ".join(DIRECTIONS), direction]) + var cell_size: int = maxi(1, int(parameters.get("cell_size", maxi(8, mini(width, height) / 8)))) + + # --- Build the image --- + var image := Image.create(width, height, false, Image.FORMAT_RGBA8) + image.fill(background_color) + _draw_shape(image, shape, fill_color, outline_color, outline_width, background_color, cell_size, direction) + + # --- Optional label overlay --- + var label := str(parameters.get("label", "")) + var warnings: Array[String] = [] + if label != "": + if Modules.VersionUtils.is_headless(): + warnings.append("label overlay skipped: text rasterization needs a display (unavailable in --headless)") + else: + var label_color := _parse_color(parameters.get("label_color", null), Color(0, 0, 0, 1)) + var label_img = await _render_label(label, width, height, label_color) + if label_img != null: + image.blend_rect(label_img, Rect2i(0, 0, width, height), Vector2i(0, 0)) + else: + warnings.append("label overlay could not be rendered") + + # --- Write + settle (shared bracket) --- + var write_fn := func(write_path: String) -> Dictionary: + var err := image.save_png(write_path) + if err != OK: + return MCPToolkitError.fail("WRITE_FAILED", + "Image.save_png failed for %s (err %d)" % [write_path, err]) + return {} + + var if_exists := str(parameters.get("if_exists", "return")) + # Default 0 = no import-settle poll: a generated PNG is a Texture2D by + # construction, so the class is reported directly (no FS round-trip). Pass an + # explicit wait_for_scan_ms > 0 to force a settle. + var wait_for_scan_ms := int(parameters.get("wait_for_scan_ms", 0)) + var result := await Helpers.write_asset_with_settle( + file_path, PackedStringArray(ALLOWED_EXTS), if_exists, wait_for_scan_ms, "texture.generate", write_fn, "Texture2D") + if not result.get("success", false): + return result + + if result.get("status") != "returned": + result["shape"] = shape + result["width"] = width + result["height"] = height + if not warnings.is_empty(): + var existing: Array = result.get("warnings", []) + existing.append_array(warnings) + result["warnings"] = existing + return result + + +# -- Drawing ------------------------------------------------------------------ + + +static func _draw_shape( + image: Image, shape: String, fill: Color, outline: Color, + outline_width: int, background: Color, cell_size: int, direction: String, +) -> void: + var w := image.get_width() + var h := image.get_height() + match shape: + "solid": + # Fast path: rectangle fills, no per-pixel loop (large images stay quick). + var ow := outline_width if outline.a > 0.0 else 0 + if ow > 0 and w > 2 * ow and h > 2 * ow: + image.fill(outline) + image.fill_rect(Rect2i(ow, ow, w - 2 * ow, h - 2 * ow), fill if fill.a > 0.0 else background) + else: + image.fill(fill if fill.a > 0.0 else (outline if outline.a > 0.0 else background)) + return + "checkerboard": + # Per-cell fills (fill_color squares over the already-filled background). + var cells_x := int(ceil(float(w) / cell_size)) + var cells_y := int(ceil(float(h) / cell_size)) + for cy in range(cells_y): + for cx in range(cells_x): + if (cx + cy) % 2 == 0: + var rx := cx * cell_size + var ry := cy * cell_size + image.fill_rect(Rect2i(rx, ry, mini(cell_size, w - rx), mini(cell_size, h - ry)), fill) + return + "grid": + # Lines (outline_color, falling back to fill) over the background. + var line_color := outline if outline.a > 0.0 else fill + var lw := maxi(1, outline_width) + var gx := 0 + while gx < w: + image.fill_rect(Rect2i(gx, 0, mini(lw, w - gx), h), line_color) + gx += cell_size + var gy := 0 + while gy < h: + image.fill_rect(Rect2i(0, gy, w, mini(lw, h - gy)), line_color) + gy += cell_size + return + _: + # Convex shapes via inside-tests at two margins (fill inset by the + # outline width); pixels in the band become the outline. + var ow := outline_width if outline.a > 0.0 else 0 + for y in range(h): + for x in range(w): + var inside_outer := _in_shape(shape, x, y, w, h, direction, 0) + if not inside_outer: + continue + var inside_inner := _in_shape(shape, x, y, w, h, direction, ow) + if inside_inner: + # Interior: fill, or leave background when fill is absent (hollow). + if fill.a > 0.0: + image.set_pixel(x, y, fill) + elif outline.a > 0.0: + # Outline band (inside outer shape, outside the inset inner shape). + image.set_pixel(x, y, outline) + + +## True when pixel (x,y) is inside the shape, shrunk inward by `margin` pixels. +static func _in_shape(shape: String, x: int, y: int, w: int, h: int, direction: String, margin: float) -> bool: + var px := x + 0.5 + var py := y + 0.5 + match shape: + "solid": + return px >= margin and px <= w - margin and py >= margin and py <= h - margin + "circle": + var cx := w / 2.0 + var cy := h / 2.0 + var r := minf(w, h) / 2.0 - margin + return r > 0.0 and Vector2(px - cx, py - cy).length() <= r + "diamond": + var hw := w / 2.0 - margin + var hh := h / 2.0 - margin + if hw <= 0.0 or hh <= 0.0: + return false + return abs(px - w / 2.0) / hw + abs(py - h / 2.0) / hh <= 1.0 + "triangle": + return _in_directional_triangle(px, py, w, h, "up", margin) + "arrow": + return _in_directional_triangle(px, py, w, h, direction, margin) + return false + + +## Triangle inscribed in the box with its apex toward `direction`, inset by margin. +static func _in_directional_triangle(px: float, py: float, w: float, h: float, direction: String, margin: float) -> bool: + # Map to a canonical "apex up" coordinate (a, b) in the same box. + var a := px + var b := py + var ww := w + var hh := h + match direction: + "up": + a = px; b = py; ww = w; hh = h + "down": + a = px; b = h - py; ww = w; hh = h + "left": + a = py; b = px; ww = h; hh = w + "right": + a = py; b = w - px; ww = h; hh = w + # Canonical apex-up: apex at (ww/2, 0), base at b = hh. + var top := margin + var bottom := hh - margin + if b < top or b > bottom: + return false + var t := (b - top) / maxf(bottom - top, 0.0001) # 0 at apex, 1 at base + var half := (ww / 2.0 - margin) * t + return half > 0.0 and abs(a - ww / 2.0) <= half + + +# -- Label rasterization (editor only) ---------------------------------------- + + +## Render `text` centred into a transparent SubViewport and return its Image, +## or null on failure. Requires a display server (guarded by the caller). +static func _render_label(text: String, width: int, height: int, color: Color) -> Variant: + var tree := Engine.get_main_loop() as SceneTree + if tree == null or tree.root == null: + return null + var viewport := SubViewport.new() + viewport.size = Vector2i(width, height) + viewport.transparent_bg = true + viewport.render_target_update_mode = SubViewport.UPDATE_ONCE + viewport.disable_3d = true + var label := Label.new() + label.text = text + label.add_theme_color_override("font_color", color) + label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + # Fill the fixed-size SubViewport via anchors. No explicit .size — setting it + # alongside FULL_RECT anchors emits a control.cpp:1440 "size overridden after + # _ready" warning, and the anchors already produce the same width×height rect. + label.set_anchors_preset(Control.PRESET_FULL_RECT) + viewport.add_child(label) + tree.root.add_child(viewport) + # Let the viewport render once. + await tree.process_frame + await RenderingServer.frame_post_draw + var texture := viewport.get_texture() + var img: Variant = null + if texture != null: + img = texture.get_image() + tree.root.remove_child(viewport) + viewport.queue_free() + return img + + +# -- Colour parsing ----------------------------------------------------------- + + +## Parse a colour from a hex/named string, an [r,g,b(,a)] array (0-1 or 0-255), +## or null (returns default_color). Alpha 0 = "layer absent". +static func _parse_color(raw: Variant, default_color: Color) -> Color: + match typeof(raw): + TYPE_NIL: + return default_color + TYPE_COLOR: + return raw + TYPE_STRING: + return Color.from_string(str(raw), default_color) + TYPE_ARRAY: + var a := raw as Array + if a.size() >= 3: + var r := float(a[0]) + var g := float(a[1]) + var b := float(a[2]) + var alpha := float(a[3]) if a.size() >= 4 else 1.0 + if r > 1.0 or g > 1.0 or b > 1.0 or alpha > 1.0: + r /= 255.0 + g /= 255.0 + b /= 255.0 + if alpha > 1.0: + alpha /= 255.0 + return Color(r, g, b, alpha) + return default_color diff --git a/addons/godot_mcp_toolkit/commands/texture_commands.gd.uid b/addons/godot_mcp_toolkit/commands/texture_commands.gd.uid new file mode 100644 index 0000000..ece920c --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/texture_commands.gd.uid @@ -0,0 +1 @@ +uid://dux0nfvq2y70l diff --git a/addons/godot_mcp_toolkit/commands/theme_commands.gd b/addons/godot_mcp_toolkit/commands/theme_commands.gd new file mode 100644 index 0000000..47d45ba --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/theme_commands.gd @@ -0,0 +1,208 @@ +@tool +extends RefCounted +## theme.* command handlers — create/modify Theme resources. + +const Modules := preload("res://addons/godot_mcp_toolkit/core/modules.gd") +const FileGuard = Modules.FileGuard +const Helpers = Modules.CommandHelpers + +const _VALID_PROPERTY_TYPES := ["color", "constant", "font", "font_size", "icon", "stylebox"] + + +static func register(registry: MCPToolkitCommandRegistry, _server: Node) -> void: + registry.add("theme.edit", func(parameters: Dictionary) -> Dictionary: + return await _cmd_theme_edit(parameters) + , MCPToolkitCommandOptions.new().mark_scene_independent()) + + +# -- Commands ----------------------------------------------------------------- + + +static func _cmd_theme_edit(parameters: Dictionary) -> Dictionary: + var err = MCPToolkitError.require(parameters, ["file_path", "edits"]) + if err != null: + return err + + var file_path := str(parameters.get("file_path", "")) + file_path = Helpers.normalize_editor_path(file_path) + var edits_raw = parameters.get("edits", null) + + if typeof(edits_raw) != TYPE_ARRAY: + return MCPToolkitError.fail("INVALID_PARAMS", "edits must be an Array of edit descriptors") + + var guard := FileGuard.resolve_safe(file_path) + if guard["error"] != null: + return MCPToolkitError.fail("PATH_DENIED", str(guard["reason"])) + if not file_path.get_extension().to_lower() in ["tres", "res"]: + return MCPToolkitError.fail("INVALID_PATH", + "theme.edit writes .tres/.res files (got %s)" % file_path) + + # Load existing or create new Theme. + var theme: Theme + if FileAccess.file_exists(file_path): + var loaded = ResourceLoader.load(file_path, "", ResourceLoader.CACHE_MODE_IGNORE) + if loaded == null or not (loaded is Theme): + return MCPToolkitError.fail("INVALID_CLASS", + "Resource at %s is not a Theme" % file_path) + theme = loaded as Theme + else: + theme = Theme.new() + + var edits: Array = edits_raw + var edits_applied := 0 + + for i in range(edits.size()): + var edit = edits[i] + if typeof(edit) != TYPE_DICTIONARY: + return MCPToolkitError.fail("INVALID_PARAMS", + "edits[%d] must be a dictionary" % i) + + # Validate required keys per edit. + for key in ["type_name", "property_type", "property", "value"]: + if not edit.has(key): + return MCPToolkitError.fail("INVALID_PARAMS", + "edits[%d] missing required key '%s'" % [i, key]) + + var type_name := str(edit["type_name"]) + var property_type := str(edit["property_type"]) + var property_name := str(edit["property"]) + var value = edit["value"] + + if property_type not in _VALID_PROPERTY_TYPES: + return MCPToolkitError.fail("INVALID_PARAMS", + "edits[%d] invalid property_type '%s'; must be one of: %s" % [ + i, property_type, ", ".join(_VALID_PROPERTY_TYPES)]) + + var apply_err := _apply_edit(theme, type_name, property_type, property_name, value, i) + if not apply_err.is_empty(): + return MCPToolkitError.fail("INVALID_PARAMS", apply_err) + edits_applied += 1 + + # Save. + var dir_result := Helpers.ensure_parent_dir(file_path, "theme.edit") + if dir_result.has("error"): + return dir_result + var save_err := ResourceSaver.save(theme, file_path) + if save_err != OK: + return MCPToolkitError.fail("SAVE_FAILED", + "ResourceSaver.save returned %d (path=%s)" % [save_err, file_path]) + ResourceLoader.load(file_path, "", ResourceLoader.CACHE_MODE_REPLACE) + await Helpers.ensure_file_indexed(file_path) + + return MCPToolkitSuccess.ok({ + "path": file_path, + "edits_applied": edits_applied, + }) + + +# -- Edit helpers ------------------------------------------------------------- + + +## Project a {r,g,b,a} dict onto a Color with opaque-BLACK channel defaults +## (paint semantics: r/g/b default 0.0, a defaults 1.0). Distinct from +## Coerce.color_from_dict (opaque-WHITE / tint) — do not conflate. Non-dict -> opaque black. +static func _color_from_dict_opaque(d: Variant) -> Color: + if typeof(d) != TYPE_DICTIONARY: + return Color(0, 0, 0, 1) + return Color( + float(d.get("r", 0.0)), float(d.get("g", 0.0)), + float(d.get("b", 0.0)), float(d.get("a", 1.0))) + + +static func _apply_edit( + theme: Theme, type_name: String, property_type: String, + property_name: String, value: Variant, index: int, +) -> String: + match property_type: + "color": + if typeof(value) != TYPE_DICTIONARY: + return "edits[%d] color value must be {r, g, b, a?}" % index + theme.set_color(property_name, type_name, _color_from_dict_opaque(value)) + + "constant": + theme.set_constant(property_name, type_name, int(value)) + + "font": + var font_path := str(value) + var font = load(font_path) + if font == null or not (font is Font): + return "edits[%d] font not found or not a Font: %s" % [index, font_path] + theme.set_font(property_name, type_name, font as Font) + + "font_size": + theme.set_font_size(property_name, type_name, int(value)) + + "icon": + var icon_path := str(value) + var icon = load(icon_path) + if icon == null or not (icon is Texture2D): + return "edits[%d] icon not found or not a Texture2D: %s" % [index, icon_path] + theme.set_icon(property_name, type_name, icon as Texture2D) + + "stylebox": + if typeof(value) != TYPE_DICTIONARY: + return "edits[%d] stylebox value must be a dictionary with 'type' key" % index + var sb_type := str(value.get("type", "")) + var sb = _create_stylebox(sb_type, value, index) + if sb is String: + return sb as String + theme.set_stylebox(property_name, type_name, sb as StyleBox) + + return "" + + +static func _create_stylebox(sb_type: String, value: Dictionary, index: int) -> Variant: + match sb_type: + "StyleBoxFlat": + var sb := StyleBoxFlat.new() + if value.has("bg_color") and typeof(value["bg_color"]) == TYPE_DICTIONARY: + sb.bg_color = _color_from_dict_opaque(value["bg_color"]) + if value.has("corner_radius"): + var r := int(value["corner_radius"]) + sb.corner_radius_top_left = r + sb.corner_radius_top_right = r + sb.corner_radius_bottom_left = r + sb.corner_radius_bottom_right = r + if value.has("content_margin"): + var m := float(value["content_margin"]) + sb.content_margin_left = m + sb.content_margin_top = m + sb.content_margin_right = m + sb.content_margin_bottom = m + if value.has("border_width"): + var w := int(value["border_width"]) + sb.border_width_left = w + sb.border_width_top = w + sb.border_width_right = w + sb.border_width_bottom = w + if value.has("border_color") and typeof(value["border_color"]) == TYPE_DICTIONARY: + sb.border_color = _color_from_dict_opaque(value["border_color"]) + return sb + + "StyleBoxTexture": + var sb := StyleBoxTexture.new() + if value.has("texture"): + var tex = load(str(value["texture"])) + if tex == null or not (tex is Texture2D): + return "edits[%d] stylebox texture not found: %s" % [index, str(value["texture"])] + sb.texture = tex as Texture2D + if value.has("content_margin"): + var m := float(value["content_margin"]) + sb.content_margin_left = m + sb.content_margin_top = m + sb.content_margin_right = m + sb.content_margin_bottom = m + return sb + + "StyleBoxLine": + var sb := StyleBoxLine.new() + if value.has("color") and typeof(value["color"]) == TYPE_DICTIONARY: + sb.color = _color_from_dict_opaque(value["color"]) + if value.has("thickness"): + sb.thickness = int(value["thickness"]) + if value.has("vertical"): + sb.vertical = bool(value["vertical"]) + return sb + + _: + return "edits[%d] unknown stylebox type '%s'; must be StyleBoxFlat, StyleBoxTexture, or StyleBoxLine" % [index, sb_type] diff --git a/addons/godot_mcp_toolkit/commands/theme_commands.gd.uid b/addons/godot_mcp_toolkit/commands/theme_commands.gd.uid new file mode 100644 index 0000000..0c9157e --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/theme_commands.gd.uid @@ -0,0 +1 @@ +uid://dwknavgut65ye diff --git a/addons/godot_mcp_toolkit/commands/tilemap_commands.gd b/addons/godot_mcp_toolkit/commands/tilemap_commands.gd new file mode 100644 index 0000000..387b517 --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/tilemap_commands.gd @@ -0,0 +1,314 @@ +@tool +extends RefCounted +## tilemap.* command handlers — batch cell painting with UndoRedo. + +const Modules := preload("res://addons/godot_mcp_toolkit/core/modules.gd") +const Coerce = Modules.Coerce +const FileGuard = Modules.FileGuard +const Helpers = Modules.CommandHelpers + + +static func register(registry: MCPToolkitCommandRegistry, server: Node) -> void: + registry.add("tilemap.set_cells", func(parameters: Dictionary) -> Dictionary: + return _cmd_tilemap_set_cells(server, parameters) + , MCPToolkitCommandOptions.new()) + registry.add("tilemap.read_cells", func(parameters: Dictionary) -> Dictionary: + return _cmd_tilemap_read_cells(parameters) + , MCPToolkitCommandOptions.new().mark_read_only()) + + +# -- Helpers ------------------------------------------------------------------ + + +static func _resolve_scene_node(node_path: String) -> Variant: + return Helpers.resolve_scene_node(node_path) + + +const _MAX_CELLS := 500 + + +## Version-aware hint for tilemap tools. +static func _tilemap_version_hint(is_deprecated_tilemap: bool) -> String: + if is_deprecated_tilemap: + var ver := Modules.VersionUtils.get_engine_version_pair() + if Modules.VersionUtils.is_at_least(ver, "4.3"): + return "Using deprecated TileMap node. Godot 4.3+ provides TileMapLayer — consider upgrading." + return "" + return "" + + +# -- Commands ----------------------------------------------------------------- + + +static func _cmd_tilemap_read_cells(parameters: Dictionary) -> Dictionary: + var node_path := str(parameters.get("node_path", "")) + node_path = Helpers.normalize_editor_path(node_path) + if node_path.is_empty(): + return MCPToolkitError.fail("INVALID_PARAMS", "missing node_path") + + var node = _resolve_scene_node(node_path) + if node == null: + return MCPToolkitError.fail("NOT_FOUND", "no node at %s" % node_path, MCPToolkitError.HINT_NODE_PATH) + + var is_layer: bool = node.is_class("TileMapLayer") + var is_map := node is TileMap + if not (is_layer or is_map): + return MCPToolkitError.fail("INVALID_CLASS", + "node at %s is not a TileMap or TileMapLayer (got %s)" % [ + node_path, node.get_class()]) + + var layer := int(parameters.get("layer", 0)) + var region_raw = parameters.get("region", null) + var source_filter := -1 + if parameters.has("source_id"): + source_filter = int(parameters["source_id"]) + + # Validate layer for deprecated TileMap + if is_map: + var tile_map := node as TileMap + var layer_count := tile_map.get_layers_count() + if layer < 0 or layer >= layer_count: + return MCPToolkitError.fail("INVALID_PARAMS", + "layer %d out of range [0, %d) for TileMap %s" % [ + layer, layer_count, node_path]) + + # Gather used cells + var used_cells: Array + if is_layer: + used_cells = node.get_used_cells() + else: + used_cells = (node as TileMap).get_used_cells(layer) + + # Compute bounding rect from all used cells + var bounds_min := Vector2i(0, 0) + var bounds_max := Vector2i(0, 0) + if not used_cells.is_empty(): + bounds_min = used_cells[0] + bounds_max = used_cells[0] + for cell_coord in used_cells: + var c: Vector2i = cell_coord + bounds_min.x = mini(bounds_min.x, c.x) + bounds_min.y = mini(bounds_min.y, c.y) + bounds_max.x = maxi(bounds_max.x, c.x) + bounds_max.y = maxi(bounds_max.y, c.y) + var bounds := { + "x": bounds_min.x, "y": bounds_min.y, + "width": bounds_max.x - bounds_min.x + 1, + "height": bounds_max.y - bounds_min.y + 1, + } + + # Filter by region + var region_rect: Rect2i + var has_region := false + if region_raw != null and typeof(region_raw) == TYPE_DICTIONARY: + has_region = true + region_rect = Rect2i( + int(region_raw.get("x", 0)), int(region_raw.get("y", 0)), + int(region_raw.get("width", 1)), int(region_raw.get("height", 1))) + + # Build cell data + var cells: Array = [] + var cells_total := 0 + var truncated := false + for cell_coord in used_cells: + var c: Vector2i = cell_coord + # Region filter + if has_region and not region_rect.has_point(c): + continue + # Read cell data + var sid: int + var atlas: Vector2i + var alt: int + if is_layer: + sid = node.get_cell_source_id(c) + atlas = node.get_cell_atlas_coords(c) + alt = node.get_cell_alternative_tile(c) + else: + var tile_map := node as TileMap + sid = tile_map.get_cell_source_id(layer, c) + atlas = tile_map.get_cell_atlas_coords(layer, c) + alt = tile_map.get_cell_alternative_tile(layer, c) + # Source filter + if source_filter >= 0 and sid != source_filter: + continue + cells_total += 1 + if cells.size() < _MAX_CELLS: + cells.append({ + "coords": {"x": c.x, "y": c.y}, + "source_id": sid, + "atlas_coords": {"x": atlas.x, "y": atlas.y}, + "alternative_tile": alt, + }) + else: + truncated = true + + # total_cells: full match count (counted past the cap); cursor-less — use + # 'region' to query spatial subsets. + var paging_hint := "" + if truncated: + paging_hint = ( + "%d of %d cells returned (capped at %d) — " % [cells.size(), cells_total, _MAX_CELLS] + + "use the 'region' parameter {x, y, width, height} to query spatial subsets (cursor-less).") + var result := Modules.Pagination.build( + {"cells": cells}, "cells", cells_total, cells.size(), truncated, "", 0, + paging_hint, {"bounds": bounds, "node_class": node.get_class()}) + + # The version hint is a compatibility note, not a paging cue — surface it even on + # a complete read, appending to any paging hint the envelope already carries. + var version_hint := _tilemap_version_hint(is_map) + if not version_hint.is_empty(): + if result.has("hint"): + result["hint"] = str(result["hint"]) + " " + version_hint + else: + result["hint"] = version_hint + + return MCPToolkitSuccess.ok(result) + + +## Expand region descriptors into flat cell array. +static func _expand_regions_to_cells(regions: Array) -> Array: + var expanded := [] + for region in regions: + if typeof(region) != TYPE_DICTIONARY: + continue + var rx := int(region.get("x", 0)) + var ry := int(region.get("y", 0)) + var rw := int(region.get("width", 1)) + var rh := int(region.get("height", 1)) + if rw <= 0 or rh <= 0: + continue + var sid := int(region.get("source_id", -1)) + var ax := int(region.get("atlas_x", 0)) + var ay := int(region.get("atlas_y", 0)) + var alt := int(region.get("alternative_tile", 0)) + for cy in range(rh): + for cx in range(rw): + expanded.append({ + "x": rx + cx, "y": ry + cy, + "source_id": sid, "atlas_x": ax, "atlas_y": ay, + "alternative_tile": alt, + }) + return expanded + + +static func _cmd_tilemap_set_cells( + server: Node, parameters: Dictionary, +) -> Dictionary: + var node_path := str(parameters.get("node_path", "")) + node_path = Helpers.normalize_editor_path(node_path) + var layer := int(parameters.get("layer", 0)) + var cells_raw = parameters.get("cells", null) + var regions_raw = parameters.get("regions", null) + if node_path.is_empty(): + return MCPToolkitError.fail("INVALID_PARAMS", "missing node_path") + + # Expand regions into cells. + if regions_raw != null and typeof(regions_raw) == TYPE_ARRAY: + var expanded := _expand_regions_to_cells(regions_raw) + if cells_raw != null and typeof(cells_raw) == TYPE_ARRAY: + cells_raw = (cells_raw as Array) + expanded + else: + cells_raw = expanded + + if typeof(cells_raw) != TYPE_ARRAY: + return MCPToolkitError.fail("INVALID_PARAMS", + "cells or regions must be provided. cells: Array of {x,y,source_id,atlas_x,atlas_y,alternative_tile?}. " + + "regions: Array of {x,y,width,height,source_id,atlas_x,atlas_y,alternative_tile?} for bulk rectangular fills.") + var cells: Array = cells_raw + var node = _resolve_scene_node(node_path) + if node == null: + return MCPToolkitError.fail("NOT_FOUND", "no node at %s" % node_path, MCPToolkitError.HINT_NODE_PATH) + var is_layer: bool = node.is_class("TileMapLayer") # dynamic — avoids parse error on < 4.3 + var is_map := node is TileMap + if not (is_layer or is_map): + return MCPToolkitError.fail("INVALID_CLASS", + "node at %s is not a TileMap or TileMapLayer (got %s); tilemap.set_cells only accepts tilemap-family nodes" % [ + node_path, node.get_class()]) + + # Validate that a tileset is assigned — placing cells without one + # silently produces invisible tiles. + var has_tileset: bool + if is_layer: + has_tileset = node.get("tile_set") != null + else: + has_tileset = (node as TileMap).tile_set != null + if not has_tileset: + return MCPToolkitError.fail("INVALID_STATE", + "no tileset assigned to %s — cells would be invisible. " % node_path + + "Use node_set_property with {\"type\": \"Resource\", \"path\": \"res://path/to/tileset.tres\"} to set tile_set first.") + + var required_keys := ["x", "y", "source_id", "atlas_x", "atlas_y"] + for cell_index in range(cells.size()): + var cell = cells[cell_index] + if typeof(cell) != TYPE_DICTIONARY: + return MCPToolkitError.fail("INVALID_PARAMS", + "cells[%d] must be an object" % cell_index) + for key in required_keys: + if not cell.has(key): + return MCPToolkitError.fail("INVALID_PARAMS", + "cells[%d] missing required key '%s'" % [cell_index, key]) + + if is_map: + var tile_map := node as TileMap + var layer_count := tile_map.get_layers_count() + if layer < 0 or layer >= layer_count: + return MCPToolkitError.fail("INVALID_PARAMS", + "layer %d out of range [0, %d) for TileMap %s" % [ + layer, layer_count, node_path]) + + var before_state: Array = [] + for cell_index in range(cells.size()): + var cell: Dictionary = cells[cell_index] + var coord := Vector2i(int(cell["x"]), int(cell["y"])) + var previous_source: int + var previous_atlas: Vector2i + var previous_alternative: int + if is_layer: + previous_source = node.get_cell_source_id(coord) + previous_atlas = node.get_cell_atlas_coords(coord) + previous_alternative = node.get_cell_alternative_tile(coord) + else: + var tile_map := node as TileMap + previous_source = tile_map.get_cell_source_id(layer, coord) + previous_atlas = tile_map.get_cell_atlas_coords(layer, coord) + previous_alternative = tile_map.get_cell_alternative_tile(layer, coord) + before_state.append({ + "coord": coord, + "source_id": previous_source, + "atlas": previous_atlas, + "alternative_tile": previous_alternative, + }) + + var cells_written := 0 + var cells_unchanged := 0 + for cell_index in range(cells.size()): + var cell: Dictionary = cells[cell_index] + var previous: Dictionary = before_state[cell_index] + var new_source := int(cell["source_id"]) + var new_atlas := Vector2i(int(cell["atlas_x"]), int(cell["atlas_y"])) + var new_alternative := int(cell.get("alternative_tile", 0)) + if int(previous["source_id"]) == new_source \ + and (previous["atlas"] as Vector2i) == new_atlas \ + and int(previous["alternative_tile"]) == new_alternative: + cells_unchanged += 1 + else: + cells_written += 1 + + server.undo_helpers._tilemap_apply_batch(node, layer, cells) + MCPToolkitUndoRedoAction.begin( + "tilemap.set_cells %s (%d cells)" % [node_path, cells.size()], node) \ + .do_method(server.undo_helpers._tilemap_apply_batch.bind(node, layer, cells)) \ + .undo_method(server.undo_helpers._tilemap_restore_batch.bind(node, layer, before_state)) \ + .do_reference(node) \ + .commit_recorded() + var set_result := { + "node_path": node_path, + "layer": layer, + "cells_written": cells_written, + "cells_unchanged": cells_unchanged, + "total": cells.size(), + } + var version_hint := _tilemap_version_hint(is_map) + if not version_hint.is_empty(): + set_result["hint"] = version_hint + return MCPToolkitSuccess.ok(set_result) diff --git a/addons/godot_mcp_toolkit/commands/tilemap_commands.gd.uid b/addons/godot_mcp_toolkit/commands/tilemap_commands.gd.uid new file mode 100644 index 0000000..e1b2fe2 --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/tilemap_commands.gd.uid @@ -0,0 +1 @@ +uid://td5lwatgaw1w diff --git a/addons/godot_mcp_toolkit/commands/tileset/tileset_alternatives.gd b/addons/godot_mcp_toolkit/commands/tileset/tileset_alternatives.gd new file mode 100644 index 0000000..28deaa1 --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/tileset/tileset_alternatives.gd @@ -0,0 +1,113 @@ +@tool +extends RefCounted +## tileset.* alternatives commands: manage alternative-tile variants on an atlas +## tile — add/remove a variant and apply its flip / transpose / modulate. +## +## Stateless — every handler takes its parameters Dictionary and returns the +## response Dictionary. Persistence and the layer-node hint come from the shared +## tileset_io.gd leaf (_IO); the modulate color projection comes from the shared +## Coerce. An extracted submodule of the tileset-command group, +## reached via a `preload` alias. + +const Modules := preload("res://addons/godot_mcp_toolkit/core/modules.gd") +const _IO := preload("res://addons/godot_mcp_toolkit/commands/tileset/tileset_io.gd") +const Coerce = Modules.Coerce + + +# -- Commands ----------------------------------------------------------------- + + +static func cmd_add_alternative(parameters: Dictionary) -> Dictionary: + var err = MCPToolkitError.require(parameters, ["file_path", "atlas_x", "atlas_y"]) + if err != null: + return err + var file_path := str(parameters.get("file_path", "")) + var source_id := int(parameters.get("source_id", 0)) + var ts_or_err = _IO.load_tileset(file_path) + if ts_or_err is Dictionary: + return ts_or_err + var ts: TileSet = ts_or_err + if not ts.has_source(source_id): + return MCPToolkitError.fail("NOT_FOUND", + "No source with id %d in TileSet" % source_id) + var source = ts.get_source(source_id) + if not (source is TileSetAtlasSource): + return MCPToolkitError.fail("INVALID_CLASS", + "Source %d is not a TileSetAtlasSource" % source_id) + var atlas: TileSetAtlasSource = source + var coord := Vector2i(int(parameters["atlas_x"]), int(parameters["atlas_y"])) + if not atlas.has_tile(coord): + return MCPToolkitError.fail("NOT_FOUND", + "tile (%d,%d) not found in source %d" % [coord.x, coord.y, source_id]) + var r = _apply_alternative(atlas, coord, parameters) + if r.has("error"): + return MCPToolkitError.fail("FAILED", r["error"]) + var alt_id: int = r["alt_id"] + var save_result := await _IO.save_tileset(ts, file_path) + if save_result.has("error"): + return save_result + return MCPToolkitSuccess.ok({ + "path": file_path, + "tile": {"atlas_x": coord.x, "atlas_y": coord.y}, + "new_alternative_id": alt_id, + "hint": "Alternative %d inherits base tile properties. Customize with tileset.edit_* tools." % alt_id, + }) + + +static func cmd_remove_alternative(parameters: Dictionary) -> Dictionary: + var err = MCPToolkitError.require(parameters, ["file_path", "atlas_x", "atlas_y", "alternative_id"]) + if err != null: + return err + var file_path := str(parameters.get("file_path", "")) + var source_id := int(parameters.get("source_id", 0)) + var atlas_x := int(parameters.get("atlas_x", 0)) + var atlas_y := int(parameters.get("atlas_y", 0)) + var alt_id := int(parameters.get("alternative_id", 0)) + var ts_or_err = _IO.load_tileset(file_path) + if ts_or_err is Dictionary: + return ts_or_err + var ts: TileSet = ts_or_err + if not ts.has_source(source_id): + return MCPToolkitError.fail("NOT_FOUND", + "No source with id %d in TileSet" % source_id) + var source = ts.get_source(source_id) + if not (source is TileSetAtlasSource): + return MCPToolkitError.fail("INVALID_CLASS", + "Source %d is not a TileSetAtlasSource" % source_id) + var atlas: TileSetAtlasSource = source + var coord := Vector2i(atlas_x, atlas_y) + if not atlas.has_tile(coord): + return MCPToolkitError.fail("NOT_FOUND", + "tile (%d,%d) not found in source %d" % [atlas_x, atlas_y, source_id]) + if not atlas.has_alternative_tile(coord, alt_id): + return MCPToolkitError.fail("NOT_FOUND", + "alternative %d not found for tile (%d,%d)" % [alt_id, atlas_x, atlas_y]) + atlas.remove_alternative_tile(coord, alt_id) + var save_result := await _IO.save_tileset(ts, file_path) + if save_result.has("error"): + return save_result + return MCPToolkitSuccess.ok({ + "path": file_path, + "removed_alternative_id": alt_id, + "tile": {"atlas_x": atlas_x, "atlas_y": atlas_y}, + "hint": _IO.layer_node_hint( + "", " cells using alternative %d revert to the base tile (alternative 0). Check with tilemap.read_cells." % alt_id), + }) + + +static func _apply_alternative( + atlas: TileSetAtlasSource, coord: Vector2i, alt: Dictionary +) -> Dictionary: + var alt_id := atlas.create_alternative_tile(coord) + var alt_td: TileData = atlas.get_tile_data(coord, alt_id) + if alt.has("flip_h"): + alt_td.flip_h = bool(alt["flip_h"]) + if alt.has("flip_v"): + alt_td.flip_v = bool(alt["flip_v"]) + if alt.has("transpose"): + alt_td.transpose = bool(alt["transpose"]) + if alt.has("modulate"): + var m = alt["modulate"] + if typeof(m) == TYPE_DICTIONARY: + alt_td.modulate = Coerce.color_from_dict(m) + return {"alt_id": alt_id} diff --git a/addons/godot_mcp_toolkit/commands/tileset/tileset_alternatives.gd.uid b/addons/godot_mcp_toolkit/commands/tileset/tileset_alternatives.gd.uid new file mode 100644 index 0000000..14a3313 --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/tileset/tileset_alternatives.gd.uid @@ -0,0 +1 @@ +uid://btkkisxlem3si diff --git a/addons/godot_mcp_toolkit/commands/tileset/tileset_commands.gd b/addons/godot_mcp_toolkit/commands/tileset/tileset_commands.gd new file mode 100644 index 0000000..aca4454 --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/tileset/tileset_commands.gd @@ -0,0 +1,51 @@ +@tool +extends RefCounted +## tileset.* command registration orchestrator — wires every tileset.* tool onto +## the registry and delegates each handler to its command child (structure, +## alternatives, tile-data). Holds no command logic itself; the I/O spine lives on +## the shared tileset_io.gd leaf, consumed by the children. + +const _Structure := preload("res://addons/godot_mcp_toolkit/commands/tileset/tileset_structure.gd") +const _Alternatives := preload("res://addons/godot_mcp_toolkit/commands/tileset/tileset_alternatives.gd") +const _TileData := preload("res://addons/godot_mcp_toolkit/commands/tileset/tileset_tile_data.gd") + + +static func register(registry: MCPToolkitCommandRegistry) -> void: + # -- tileset group (structural) -- + registry.add("tileset.create", func(parameters: Dictionary) -> Dictionary: + return await _Structure.cmd_create(parameters) + , MCPToolkitCommandOptions.new().mark_scene_independent()) + registry.add("tileset.add_source", func(parameters: Dictionary) -> Dictionary: + return await _Structure.cmd_add_source(parameters) + , MCPToolkitCommandOptions.new().mark_scene_independent()) + registry.add("tileset.remove_source", func(parameters: Dictionary) -> Dictionary: + return await _Structure.cmd_remove_source(parameters) + , MCPToolkitCommandOptions.new().mark_scene_independent()) + registry.add("tileset.add_alternative", func(parameters: Dictionary) -> Dictionary: + return await _Alternatives.cmd_add_alternative(parameters) + , MCPToolkitCommandOptions.new().mark_scene_independent()) + registry.add("tileset.remove_alternative", func(parameters: Dictionary) -> Dictionary: + return await _Alternatives.cmd_remove_alternative(parameters) + , MCPToolkitCommandOptions.new().mark_scene_independent()) + registry.add("tileset.setup_layers", func(parameters: Dictionary) -> Dictionary: + return await _Structure.cmd_setup_layers(parameters) + , MCPToolkitCommandOptions.new().mark_scene_independent()) + # -- tileset_edit group (per-tile properties) -- + # Each verb binds the shared _TileData.cmd_edit dispatcher but pins its own + # VERB, so the handler enforces the per-tile key set its tool name promises (a + # terrain key sent to edit_physics is rejected, not silently applied). + registry.add("tileset.edit_physics", func(parameters: Dictionary) -> Dictionary: + return await _TileData.cmd_edit(parameters, "physics") + , MCPToolkitCommandOptions.new().mark_scene_independent()) + registry.add("tileset.edit_terrain", func(parameters: Dictionary) -> Dictionary: + return await _TileData.cmd_edit(parameters, "terrain") + , MCPToolkitCommandOptions.new().mark_scene_independent()) + registry.add("tileset.edit_navigation", func(parameters: Dictionary) -> Dictionary: + return await _TileData.cmd_edit(parameters, "navigation") + , MCPToolkitCommandOptions.new().mark_scene_independent()) + registry.add("tileset.edit_visuals", func(parameters: Dictionary) -> Dictionary: + return await _TileData.cmd_edit(parameters, "visuals") + , MCPToolkitCommandOptions.new().mark_scene_independent()) + registry.add("tileset.edit_custom_data", func(parameters: Dictionary) -> Dictionary: + return await _TileData.cmd_edit(parameters, "custom_data") + , MCPToolkitCommandOptions.new().mark_scene_independent()) diff --git a/addons/godot_mcp_toolkit/commands/tileset/tileset_commands.gd.uid b/addons/godot_mcp_toolkit/commands/tileset/tileset_commands.gd.uid new file mode 100644 index 0000000..535f886 --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/tileset/tileset_commands.gd.uid @@ -0,0 +1 @@ +uid://djljrqfdxguca diff --git a/addons/godot_mcp_toolkit/commands/tileset/tileset_io.gd b/addons/godot_mcp_toolkit/commands/tileset/tileset_io.gd new file mode 100644 index 0000000..e7fd4cd --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/tileset/tileset_io.gd @@ -0,0 +1,58 @@ +@tool +extends RefCounted +## Shared TileSet support leaf: load, validate, save-and-reindex a TileSet +## resource, plus the layer-node version hint and the full-tile polygon every +## tileset.* command group relies on. +## +## Stateless — every function takes its inputs (file path, TileSet, tile size) as +## parameters and returns its result (a TileSet-or-error Variant, an empty-or-error +## Dictionary, a hint String, or a polygon). No cross-call state; the file is all +## static, consumed by the tileset command children via a `preload` alias. + +const Modules := preload("res://addons/godot_mcp_toolkit/core/modules.gd") +const FileGuard = Modules.FileGuard +const Helpers = Modules.CommandHelpers + + +## Load and validate a TileSet from file_path. Returns TileSet or error Dictionary. +static func load_tileset(file_path: String) -> Variant: + var guard := FileGuard.resolve_safe(file_path) + if guard["error"] != null: + return MCPToolkitError.fail("PATH_DENIED", str(guard["reason"])) + if not FileAccess.file_exists(file_path): + return MCPToolkitError.fail("NOT_FOUND", "TileSet not found: %s" % file_path) + var ts = ResourceLoader.load(file_path, "", ResourceLoader.CACHE_MODE_IGNORE) + if ts == null or not (ts is TileSet): + return MCPToolkitError.fail("INVALID_CLASS", + "Resource at %s is not a TileSet" % file_path) + return ts + + +## Save a TileSet, reload cache, and index. Returns empty dict on success or error. +static func save_tileset(ts: TileSet, file_path: String) -> Dictionary: + var save_err := ResourceSaver.save(ts, file_path) + if save_err != OK: + return MCPToolkitError.fail("SAVE_FAILED", + "ResourceSaver.save returned %d (path=%s)" % [save_err, file_path]) + ResourceLoader.load(file_path, "", ResourceLoader.CACHE_MODE_REPLACE) + await Helpers.ensure_file_indexed(file_path) + return {} + + +## Build the response-tail hint that names the node a TileSet attaches to — +## TileMapLayer on 4.3+, TileMap on 4.2 — wrapped in the caller's prefix/suffix. +static func layer_node_hint(prefix: String, suffix: String) -> String: + var ver := Modules.VersionUtils.get_engine_version_pair() + var has_tilemaplayer := Modules.VersionUtils.is_at_least(ver, "4.3") + var node_name := "TileMapLayer" if has_tilemaplayer else "TileMap" + return prefix + node_name + suffix + + +## The unit rectangle spanning one tile (±w/2, ±h/2) — the default collision/nav/ +## occlusion shape and the "full"/"one_way" collision shorthand. +static func build_full_tile_polygon(tile_size: Vector2i) -> PackedVector2Array: + var hw := tile_size.x / 2.0 + var hh := tile_size.y / 2.0 + return PackedVector2Array([ + Vector2(-hw, -hh), Vector2(hw, -hh), + Vector2(hw, hh), Vector2(-hw, hh)]) diff --git a/addons/godot_mcp_toolkit/commands/tileset/tileset_io.gd.uid b/addons/godot_mcp_toolkit/commands/tileset/tileset_io.gd.uid new file mode 100644 index 0000000..382b3ba --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/tileset/tileset_io.gd.uid @@ -0,0 +1 @@ +uid://dralmnmahnxw7 diff --git a/addons/godot_mcp_toolkit/commands/tileset/tileset_structure.gd b/addons/godot_mcp_toolkit/commands/tileset/tileset_structure.gd new file mode 100644 index 0000000..042a031 --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/tileset/tileset_structure.gd @@ -0,0 +1,274 @@ +@tool +extends RefCounted +## tileset.* structure commands: define a TileSet's atlas-source set and layer +## skeleton — create-from-texture, add/remove source, and setup the layer counts. +## +## Stateless — every handler takes its parameters Dictionary and returns the +## response Dictionary. Persistence, the layer-node hint, and the full-tile polygon +## come from the shared tileset_io.gd leaf (_IO); this module owns only the +## structural-shape logic. An extracted submodule of the tileset-command group, +## reached via a `preload` alias. + +const Modules := preload("res://addons/godot_mcp_toolkit/core/modules.gd") +const _IO := preload("res://addons/godot_mcp_toolkit/commands/tileset/tileset_io.gd") +const Coerce = Modules.Coerce +const FileGuard = Modules.FileGuard +const Helpers = Modules.CommandHelpers + + +# -- Commands ----------------------------------------------------------------- + + +static func cmd_create(parameters: Dictionary) -> Dictionary: + var err = MCPToolkitError.require(parameters, ["file_path", "texture_path"]) + if err != null: + return err + var file_path := str(parameters.get("file_path", "")) + var texture_path := str(parameters.get("texture_path", "")) + var tile_size_raw = parameters.get("tile_size", {"x": 16, "y": 16}) + var tile_w := int(tile_size_raw.get("x", 16)) if typeof(tile_size_raw) == TYPE_DICTIONARY else 16 + var tile_h := int(tile_size_raw.get("y", 16)) if typeof(tile_size_raw) == TYPE_DICTIONARY else 16 + + var guard := FileGuard.resolve_safe(file_path) + if guard["error"] != null: + return MCPToolkitError.fail("PATH_DENIED", str(guard["reason"])) + if not file_path.get_extension().to_lower() in ["tres", "res"]: + return MCPToolkitError.fail("INVALID_PATH", + "tileset_create writes .tres/.res files (got %s)" % file_path) + + var texture: Texture2D = load(texture_path) as Texture2D + if texture == null: + return MCPToolkitError.fail("NOT_FOUND", + "texture not found or not a Texture2D: %s" % texture_path) + + var ts := TileSet.new() + ts.tile_size = Vector2i(tile_w, tile_h) + + var physics: bool = parameters.get("physics", true) + if physics: + ts.add_physics_layer() + var collision_layer := Coerce.layers_to_mask(parameters.get("collision_layer", 1)) + var collision_mask := Coerce.layers_to_mask(parameters.get("collision_mask", 1)) + ts.set_physics_layer_collision_layer(0, collision_layer) + ts.set_physics_layer_collision_mask(0, collision_mask) + + var source := TileSetAtlasSource.new() + source.texture = texture + source.texture_region_size = Vector2i(tile_w, tile_h) + var source_id := ts.add_source(source) + + var tex_size := texture.get_size() + var cols := int(tex_size.x) / tile_w + var rows := int(tex_size.y) / tile_h + var tiles_created := 0 + var full_tile_polygon := _IO.build_full_tile_polygon(ts.tile_size) + for row in range(rows): + for col in range(cols): + var atlas_coord := Vector2i(col, row) + source.create_tile(atlas_coord) + if physics: + var td: TileData = source.get_tile_data(atlas_coord, 0) + td.add_collision_polygon(0) + td.set_collision_polygon_points(0, 0, full_tile_polygon) + tiles_created += 1 + + var dir_result := Helpers.ensure_parent_dir(file_path, "tileset.create") + if dir_result.has("error"): + return dir_result + var save_err := ResourceSaver.save(ts, file_path) + if save_err != OK: + return MCPToolkitError.fail("SAVE_FAILED", + "ResourceSaver.save returned %d (path=%s)" % [save_err, file_path]) + var loaded := ResourceLoader.load(file_path, "TileSet", ResourceLoader.CACHE_MODE_REPLACE) + if loaded == null or not (loaded is TileSet): + return MCPToolkitError.fail("SAVE_FAILED", + "tileset saved but reload failed — file may be corrupt: %s" % file_path) + await Helpers.ensure_file_indexed(file_path) + + var create_result := { + "path": file_path, + "tile_size": {"x": tile_w, "y": tile_h}, + "source_id": source_id, + "atlas_grid": {"columns": cols, "rows": rows}, + "tiles_created": tiles_created, + "physics": physics, + } + create_result["hint"] = _IO.layer_node_hint("Assign this TileSet to a ", " node.") + return MCPToolkitSuccess.ok(create_result) + + +static func cmd_add_source(parameters: Dictionary) -> Dictionary: + var err = MCPToolkitError.require(parameters, ["file_path", "texture_path"]) + if err != null: + return err + var file_path := str(parameters.get("file_path", "")) + var ts_or_err = _IO.load_tileset(file_path) + if ts_or_err is Dictionary: + return ts_or_err + var ts: TileSet = ts_or_err + var result = _apply_add_source(ts, parameters) + if result.has("error"): + return result + var save_result := await _IO.save_tileset(ts, file_path) + if save_result.has("error"): + return save_result + var source_id: int = result["source_id"] + return MCPToolkitSuccess.ok({ + "path": file_path, + "new_source_id": source_id, + "hint": _IO.layer_node_hint( + "Configure tiles on source %d with tileset.edit_* tools, or paint onto a " % source_id, + " with tilemap.set_cells."), + }) + + +static func cmd_remove_source(parameters: Dictionary) -> Dictionary: + var err = MCPToolkitError.require(parameters, ["file_path", "source_id"]) + if err != null: + return err + var file_path := str(parameters.get("file_path", "")) + var source_id := int(parameters.get("source_id", 0)) + var ts_or_err = _IO.load_tileset(file_path) + if ts_or_err is Dictionary: + return ts_or_err + var ts: TileSet = ts_or_err + if not ts.has_source(source_id): + return MCPToolkitError.fail("NOT_FOUND", + "No source with id %d in TileSet" % source_id) + ts.remove_source(source_id) + var save_result := await _IO.save_tileset(ts, file_path) + if save_result.has("error"): + return save_result + return MCPToolkitSuccess.ok({ + "path": file_path, + "removed_source_id": source_id, + "hint": _IO.layer_node_hint( + "", " cells referencing source %d may become invalid. Check with tilemap.read_cells." % source_id), + }) + + +static func cmd_setup_layers(parameters: Dictionary) -> Dictionary: + var err = MCPToolkitError.require(parameters, ["file_path"]) + if err != null: + return err + var file_path := str(parameters.get("file_path", "")) + var ts_or_err = _IO.load_tileset(file_path) + if ts_or_err is Dictionary: + return ts_or_err + var ts: TileSet = ts_or_err + var result = _apply_layers(ts, parameters) + if result.has("error"): + return result + var save_result := await _IO.save_tileset(ts, file_path) + if save_result.has("error"): + return save_result + return MCPToolkitSuccess.ok({ + "path": file_path, + "hint": _IO.layer_node_hint( + "Layers configured. Assign per-tile data with tileset.edit_physics, tileset.edit_terrain, etc., then paint onto a ", + " with tilemap.set_cells."), + }) + + +# -- structure sub-helpers ---------------------------------------------------- + + +static func _apply_add_source(ts: TileSet, cfg: Dictionary) -> Dictionary: + var tex_path := str(cfg.get("texture_path", "")) + if tex_path.is_empty(): + return MCPToolkitError.fail("INVALID_PARAMS", "add_source.texture_path required") + var texture: Texture2D = load(tex_path) as Texture2D + if texture == null: + return MCPToolkitError.fail("NOT_FOUND", + "add_source texture not found: %s" % tex_path) + var source := TileSetAtlasSource.new() + source.texture = texture + var tile_w := int(cfg.get("tile_size", {}).get("x", ts.tile_size.x)) + var tile_h := int(cfg.get("tile_size", {}).get("y", ts.tile_size.y)) + source.texture_region_size = Vector2i(tile_w, tile_h) + var sid := ts.add_source(source) + # Auto-create tiles + var tex_size := texture.get_size() + var cols := int(tex_size.x) / tile_w + var rows := int(tex_size.y) / tile_h + for row in range(rows): + for col in range(cols): + source.create_tile(Vector2i(col, row)) + return {"source_id": sid} + + +static func _apply_layers(ts: TileSet, layers: Dictionary) -> Dictionary: + # Terrain sets + if layers.has("terrain_sets") and typeof(layers["terrain_sets"]) == TYPE_ARRAY: + for ts_def in layers["terrain_sets"]: + if typeof(ts_def) != TYPE_DICTIONARY: + continue + var idx := ts.get_terrain_sets_count() + ts.add_terrain_set() + var mode_str := str(ts_def.get("mode", "match_corners_and_sides")) + var mode: int = TileSet.TERRAIN_MODE_MATCH_CORNERS_AND_SIDES + if mode_str == "match_corners": + mode = TileSet.TERRAIN_MODE_MATCH_CORNERS + elif mode_str == "match_sides": + mode = TileSet.TERRAIN_MODE_MATCH_SIDES + ts.set_terrain_set_mode(idx, mode) + if ts_def.has("terrains") and typeof(ts_def["terrains"]) == TYPE_ARRAY: + for t_name in ts_def["terrains"]: + ts.add_terrain(idx) + var t_idx := ts.get_terrains_count(idx) - 1 + ts.set_terrain_name(idx, t_idx, str(t_name)) + + # Custom data layers + if layers.has("custom_data") and typeof(layers["custom_data"]) == TYPE_ARRAY: + for cd_def in layers["custom_data"]: + if typeof(cd_def) != TYPE_DICTIONARY: + continue + var layer_name := str(cd_def.get("name", "")) + if layer_name.is_empty(): + continue + # Check if layer already exists + var exists := false + for li in range(ts.get_custom_data_layers_count()): + if ts.get_custom_data_layer_name(li) == layer_name: + exists = true + break + if exists: + continue + ts.add_custom_data_layer() + var li := ts.get_custom_data_layers_count() - 1 + ts.set_custom_data_layer_name(li, layer_name) + var type_str := str(cd_def.get("type", "int")).to_lower() + ts.set_custom_data_layer_type(li, _variant_type_from_string(type_str)) + + # Navigation layers (safe coercion via str() for non-int input) + if layers.has("navigation_layers"): + var want := int(str(layers["navigation_layers"])) + while ts.get_navigation_layers_count() < want: + ts.add_navigation_layer() + + # Occlusion layers + if layers.has("occlusion_layers"): + var want := int(str(layers["occlusion_layers"])) + while ts.get_occlusion_layers_count() < want: + ts.add_occlusion_layer() + + # Physics layers + if layers.has("physics_layers"): + var want := int(str(layers["physics_layers"])) + while ts.get_physics_layers_count() < want: + ts.add_physics_layer() + + return {} + + +static func _variant_type_from_string(s: String) -> int: + match s: + "int": return TYPE_INT + "float": return TYPE_FLOAT + "bool": return TYPE_BOOL + "string": return TYPE_STRING + "vector2": return TYPE_VECTOR2 + "vector2i": return TYPE_VECTOR2I + "vector3": return TYPE_VECTOR3 + "color": return TYPE_COLOR + _: return TYPE_INT diff --git a/addons/godot_mcp_toolkit/commands/tileset/tileset_structure.gd.uid b/addons/godot_mcp_toolkit/commands/tileset/tileset_structure.gd.uid new file mode 100644 index 0000000..a345589 --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/tileset/tileset_structure.gd.uid @@ -0,0 +1 @@ +uid://4vni6tubjoir diff --git a/addons/godot_mcp_toolkit/commands/tileset/tileset_tile_data.gd b/addons/godot_mcp_toolkit/commands/tileset/tileset_tile_data.gd new file mode 100644 index 0000000..704ae2d --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/tileset/tileset_tile_data.gd @@ -0,0 +1,399 @@ +@tool +extends RefCounted +## tileset.edit_* per-tile data commands: apply a verb-scoped set of per-tile +## TileData edits (physics / terrain / navigation / occlusion+animation / custom +## data), rejecting any key owned by a different edit verb. +## +## One parametric operation — cmd_edit(parameters, verb) — with a verb-indexed +## strategy table. The per-tile-edit policy (which keys are legal per verb, the +## _EDIT_* tables + _foreign_key_error gate) and its mechanism (how each key +## mutates TileData, the _apply_* flows + geometry builders) are inseparable, so +## they live together here. Persistence, the layer-node hint, and the full-tile +## polygon come from the shared tileset_io.gd leaf (_IO); consumed by +## tileset_commands.gd via a `preload` alias. + +const _IO := preload("res://addons/godot_mcp_toolkit/commands/tileset/tileset_io.gd") + + +# -- Per-verb key enforcement ------------------------------------------------- +# +# Each tileset.edit_* tool owns exactly one tile-data concern. The five tools all +# share cmd_edit, so without enforcement a key meant for one verb (e.g. +# terrain_set) applied silently under another (e.g. edit_physics). These tables +# make the verb authoritative: a verb accepts only its own per-tile keys plus the +# universal coordinate selectors, and any foreign key is rejected with a hint that +# names the tool that DOES own it (reverse lookup via _EDIT_KEY_OWNER). + +## Coordinate selectors every verb needs to address a tile. +const _EDIT_COORD_KEYS := ["atlas_x", "atlas_y"] + +## Per-tile keys each verb is allowed to read/apply. The key sets are mutually +## exclusive — one key belongs to exactly one verb — which is what lets a single +## reverse lookup name the owning tool for a rejection hint. +const _EDIT_KEY_SETS := { + "physics": ["physics_polygon", "physics_layer", "one_way_collision"], + "terrain": ["terrain_set", "terrain", "terrain_peering"], + "navigation": ["navigation_polygon", "navigation_layer"], + "visuals": ["occlusion_polygon", "occlusion_layer", "animation", "probability"], + "custom_data": ["custom_data"], +} + +## Reverse map: per-tile key -> the tool that owns it, for the rejection hint. +const _EDIT_KEY_OWNER := { + "physics_polygon": "tileset.edit_physics", + "physics_layer": "tileset.edit_physics", + "one_way_collision": "tileset.edit_physics", + "terrain_set": "tileset.edit_terrain", + "terrain": "tileset.edit_terrain", + "terrain_peering": "tileset.edit_terrain", + "navigation_polygon": "tileset.edit_navigation", + "navigation_layer": "tileset.edit_navigation", + "occlusion_polygon": "tileset.edit_visuals", + "occlusion_layer": "tileset.edit_visuals", + "animation": "tileset.edit_visuals", + "probability": "tileset.edit_visuals", + "custom_data": "tileset.edit_custom_data", +} + + +## Reject the first per-tile key that does not belong to `verb`. Returns "" when +## every key is allowed, otherwise an INVALID_PARAMS message that names the tool +## owning the offending key. Pure (no engine state) so it is unit-testable. +static func _foreign_key_error(verb: String, tile: Dictionary) -> String: + var allowed: Array = _EDIT_KEY_SETS.get(verb, []) + for key in tile: + var key_str := str(key) + if key_str in _EDIT_COORD_KEYS or key_str in allowed: + continue + if _EDIT_KEY_OWNER.has(key_str): + return "key '%s' belongs to %s, not tileset.edit_%s" % [ + key_str, _EDIT_KEY_OWNER[key_str], verb] + return "unknown key '%s' for tileset.edit_%s" % [key_str, verb] + return "" + + +static func cmd_edit(parameters: Dictionary, verb: String) -> Dictionary: + var err = MCPToolkitError.require(parameters, ["file_path"]) + if err != null: + return err + + var file_path := str(parameters.get("file_path", "")) + var source_id := int(parameters.get("source_id", 0)) + var tiles_raw = parameters.get("tiles", null) + + var ts_or_err = _IO.load_tileset(file_path) + if ts_or_err is Dictionary: + return ts_or_err + var ts: TileSet = ts_or_err + + var tile_errors: Array = [] + var tiles_modified := 0 + + # -- per-tile edits -- + if tiles_raw != null and typeof(tiles_raw) == TYPE_ARRAY: + if not ts.has_source(source_id): + return MCPToolkitError.fail("NOT_FOUND", + "No source with id %d in TileSet" % source_id) + var source = ts.get_source(source_id) + if not (source is TileSetAtlasSource): + return MCPToolkitError.fail("INVALID_CLASS", + "Source %d is not a TileSetAtlasSource" % source_id) + var atlas: TileSetAtlasSource = source + var tile_size: Vector2i = ts.tile_size + + for i in range(tiles_raw.size()): + var tile = tiles_raw[i] + if typeof(tile) != TYPE_DICTIONARY: + tile_errors.append("tiles[%d]: not a dictionary" % i) + continue + if not tile.has("atlas_x") or not tile.has("atlas_y"): + tile_errors.append("tiles[%d]: missing atlas_x/atlas_y" % i) + continue + # Enforce the verb: a key for another tool is a caller mistake, so + # reject the whole call (don't silently apply the wrong concern). + var foreign := _foreign_key_error(verb, tile) + if foreign != "": + return MCPToolkitError.fail("INVALID_PARAMS", + "tiles[%d]: %s" % [i, foreign]) + var coord := Vector2i(int(tile["atlas_x"]), int(tile["atlas_y"])) + if not atlas.has_tile(coord): + tile_errors.append("tiles[%d]: tile (%d,%d) not found in source %d" % [ + i, coord.x, coord.y, source_id]) + continue + var td: TileData = atlas.get_tile_data(coord, 0) + + # Count a tile only when at least one real edit key was applied — a + # tile carrying just the coord selectors applies nothing, so it must + # not be counted (matches the pre-split per-tile `modified` flag). + var has_real_edit := false + for k in tile: + if not str(k) in _EDIT_COORD_KEYS: + has_real_edit = true + break + var feature_errors: Array = _apply_verb(verb, atlas, coord, td, tile, tile_size) + if feature_errors.is_empty(): + if has_real_edit: + tiles_modified += 1 + else: + for fe in feature_errors: + tile_errors.append("tiles[%d]: %s" % [i, str(fe)]) + + # -- Save -- + var save_result := await _IO.save_tileset(ts, file_path) + if save_result.has("error"): + return save_result + + var edit_result := { + "path": file_path, + "tiles_modified": tiles_modified, + "errors": tile_errors, + "hint": _IO.layer_node_hint( + "Edit more tile properties with tileset.edit_*, or paint tiles onto a ", + " with tilemap.set_cells."), + } + return MCPToolkitSuccess.ok(edit_result) + + +# -- Intent-named per-verb flows ---------------------------------------------- +# +# One flow per tool, each applying ONLY its own concern's keys to a single tile. +# Each returns the soft per-tile errors it accumulated ([] = fully applied); the +# caller folds those into the per-tile errors[] array exactly as before. + + +## Route one tile to the flow that matches `verb`. Keys are already verb-checked. +static func _apply_verb( + verb: String, atlas: TileSetAtlasSource, coord: Vector2i, + td: TileData, tile: Dictionary, tile_size: Vector2i +) -> Array: + match verb: + "physics": + return _apply_physics(td, tile, tile_size) + "terrain": + return _apply_terrain(td, tile) + "navigation": + return _apply_navigation(td, tile, tile_size) + "visuals": + return _apply_visuals(atlas, coord, td, tile, tile_size) + "custom_data": + return _apply_custom_data(td, tile) + return ["unknown tileset edit verb: %s" % verb] + + +## Collision polygon + one-way flag (tileset.edit_physics). +static func _apply_physics(td: TileData, tile: Dictionary, tile_size: Vector2i) -> Array: + var errors: Array = [] + if tile.has("physics_polygon"): + var r := _apply_physics_polygon(td, tile, tile_size) + if not r.is_empty(): + errors.append(r) + return errors + + +## Terrain set, terrain index, and peering bits (tileset.edit_terrain). +static func _apply_terrain(td: TileData, tile: Dictionary) -> Array: + var errors: Array = [] + if tile.has("terrain_set"): + td.terrain_set = int(tile["terrain_set"]) + if tile.has("terrain"): + td.terrain = int(tile["terrain"]) + if tile.has("terrain_peering") and typeof(tile["terrain_peering"]) == TYPE_DICTIONARY: + var r := _apply_terrain_peering(td, tile["terrain_peering"]) + if not r.is_empty(): + errors.append(r) + return errors + + +## Navigation polygon (tileset.edit_navigation). +static func _apply_navigation(td: TileData, tile: Dictionary, tile_size: Vector2i) -> Array: + var errors: Array = [] + if tile.has("navigation_polygon"): + var r := _apply_navigation_polygon(td, tile, tile_size) + if not r.is_empty(): + errors.append(r) + return errors + + +## Occlusion, animation, and probability (tileset.edit_visuals). This tool +## deliberately bundles all three appearance concerns for a tile. +static func _apply_visuals( + atlas: TileSetAtlasSource, coord: Vector2i, + td: TileData, tile: Dictionary, tile_size: Vector2i +) -> Array: + var errors: Array = [] + if tile.has("occlusion_polygon"): + var r := _apply_occlusion_polygon(td, tile, tile_size) + if not r.is_empty(): + errors.append(r) + if tile.has("animation") and typeof(tile["animation"]) == TYPE_DICTIONARY: + var r := _apply_animation(atlas, coord, tile["animation"]) + if not r.is_empty(): + errors.append(r) + if tile.has("probability"): + td.probability = float(tile["probability"]) + return errors + + +## Custom data layer values (tileset.edit_custom_data). +static func _apply_custom_data(td: TileData, tile: Dictionary) -> Array: + if tile.has("custom_data") and typeof(tile["custom_data"]) == TYPE_DICTIONARY: + var cd: Dictionary = tile["custom_data"] + for layer_name in cd: + td.set_custom_data(str(layer_name), cd[layer_name]) + return [] + + +static func _ensure_collision_polygon(td: TileData, physics_layer: int) -> void: + if td.get_collision_polygons_count(physics_layer) == 0: + td.add_collision_polygon(physics_layer) + + +static func _apply_physics_polygon( + td: TileData, tile: Dictionary, tile_size: Vector2i +) -> String: + var val = tile["physics_polygon"] + var physics_layer := int(tile.get("physics_layer", 0)) + if typeof(val) == TYPE_STRING: + match val: + "full": + _ensure_collision_polygon(td, physics_layer) + td.set_collision_polygon_points(physics_layer, 0, + _IO.build_full_tile_polygon(tile_size)) + "none": + while td.get_collision_polygons_count(physics_layer) > 0: + td.remove_collision_polygon(physics_layer, 0) + "one_way": + _ensure_collision_polygon(td, physics_layer) + td.set_collision_polygon_points(physics_layer, 0, + _IO.build_full_tile_polygon(tile_size)) + td.set_collision_polygon_one_way(physics_layer, 0, true) + _: + return "unknown physics_polygon shorthand: %s" % val + elif typeof(val) == TYPE_ARRAY: + _ensure_collision_polygon(td, physics_layer) + var points := PackedVector2Array() + for pt in val: + if typeof(pt) == TYPE_DICTIONARY: + points.append(Vector2(float(pt.get("x", 0)), float(pt.get("y", 0)))) + td.set_collision_polygon_points(physics_layer, 0, points) + else: + return "physics_polygon must be string or Array[{x,y}]" + if tile.has("one_way_collision"): + _ensure_collision_polygon(td, physics_layer) + td.set_collision_polygon_one_way(physics_layer, 0, bool(tile["one_way_collision"])) + return "" + + +const _PEERING_MAP := { + "right": TileSet.CELL_NEIGHBOR_RIGHT_SIDE, + "bottom_right": TileSet.CELL_NEIGHBOR_BOTTOM_RIGHT_CORNER, + "bottom": TileSet.CELL_NEIGHBOR_BOTTOM_SIDE, + "bottom_left": TileSet.CELL_NEIGHBOR_BOTTOM_LEFT_CORNER, + "left": TileSet.CELL_NEIGHBOR_LEFT_SIDE, + "top_left": TileSet.CELL_NEIGHBOR_TOP_LEFT_CORNER, + "top": TileSet.CELL_NEIGHBOR_TOP_SIDE, + "top_right": TileSet.CELL_NEIGHBOR_TOP_RIGHT_CORNER, +} + + +static func _apply_terrain_peering(td: TileData, peering: Dictionary) -> String: + for key in peering: + if key == "center": + td.terrain = int(peering[key]) + continue + if not _PEERING_MAP.has(key): + return "unknown peering bit: %s" % key + td.set_terrain_peering_bit(_PEERING_MAP[key], int(peering[key])) + return "" + + +static func _build_nav_polygon(verts: PackedVector2Array) -> NavigationPolygon: + var np := NavigationPolygon.new() + np.set_vertices(verts) + var indices := PackedInt32Array() + for i in range(verts.size()): + indices.append(i) + np.add_polygon(indices) + return np + + +static func _apply_navigation_polygon( + td: TileData, tile: Dictionary, tile_size: Vector2i +) -> String: + var val = tile["navigation_polygon"] + var nav_layer := int(tile.get("navigation_layer", 0)) + if typeof(val) == TYPE_STRING: + match val: + "full": + var verts := _IO.build_full_tile_polygon(tile_size) + td.set_navigation_polygon(nav_layer, _build_nav_polygon(verts)) + "none": + td.set_navigation_polygon(nav_layer, null) + _: + return "unknown navigation_polygon shorthand: %s" % val + elif typeof(val) == TYPE_ARRAY: + var verts := PackedVector2Array() + for pt in val: + if typeof(pt) == TYPE_DICTIONARY: + verts.append(Vector2(float(pt.get("x", 0)), float(pt.get("y", 0)))) + td.set_navigation_polygon(nav_layer, _build_nav_polygon(verts)) + else: + return "navigation_polygon must be string or Array[{x,y}]" + return "" + + +static func _apply_occlusion_polygon( + td: TileData, tile: Dictionary, tile_size: Vector2i +) -> String: + var val = tile["occlusion_polygon"] + var occ_layer := int(tile.get("occlusion_layer", 0)) + if typeof(val) == TYPE_STRING: + match val: + "full": + var op := OccluderPolygon2D.new() + op.polygon = _IO.build_full_tile_polygon(tile_size) + td.set_occluder(occ_layer, op) + "none": + td.set_occluder(occ_layer, null) + _: + return "unknown occlusion_polygon shorthand: %s" % val + elif typeof(val) == TYPE_ARRAY: + var op := OccluderPolygon2D.new() + var verts := PackedVector2Array() + for pt in val: + if typeof(pt) == TYPE_DICTIONARY: + verts.append(Vector2(float(pt.get("x", 0)), float(pt.get("y", 0)))) + op.polygon = verts + td.set_occluder(occ_layer, op) + else: + return "occlusion_polygon must be string or Array[{x,y}]" + return "" + + +static func _apply_animation( + atlas: TileSetAtlasSource, coord: Vector2i, anim: Dictionary +) -> String: + var frame_count := int(anim.get("frame_count", anim.get("frames", []).size())) + if frame_count < 2: + return "animation needs frame_count >= 2" + var columns := int(anim.get("columns", frame_count)) + var duration := float(anim.get("frame_duration", 1.0)) + # Remove tiles that the animation area would cover (except the base tile) + var rows_needed := ceili(float(frame_count) / float(columns)) + for fy in range(rows_needed): + for fx in range(columns): + if fx == 0 and fy == 0: + continue + var covered := coord + Vector2i(fx, fy) + if atlas.has_tile(covered): + atlas.remove_tile(covered) + atlas.set_tile_animation_columns(coord, columns) + atlas.set_tile_animation_frames_count(coord, frame_count) + for f in range(frame_count): + atlas.set_tile_animation_frame_duration(coord, f, duration) + if anim.has("separation"): + var sep = anim["separation"] + if typeof(sep) == TYPE_DICTIONARY: + atlas.set_tile_animation_separation(coord, + Vector2i(int(sep.get("x", 0)), int(sep.get("y", 0)))) + return "" diff --git a/addons/godot_mcp_toolkit/commands/tileset/tileset_tile_data.gd.uid b/addons/godot_mcp_toolkit/commands/tileset/tileset_tile_data.gd.uid new file mode 100644 index 0000000..df32d34 --- /dev/null +++ b/addons/godot_mcp_toolkit/commands/tileset/tileset_tile_data.gd.uid @@ -0,0 +1 @@ +uid://o4k1q1l462yh diff --git a/addons/godot_mcp_toolkit/contract/coerce.gd b/addons/godot_mcp_toolkit/contract/coerce.gd new file mode 100644 index 0000000..a6ad154 --- /dev/null +++ b/addons/godot_mcp_toolkit/contract/coerce.gd @@ -0,0 +1,402 @@ +@tool +extends RefCounted +## Shared Variant coercion helper and its serialisation inverse. +## +## _coerce_value: JSON dict → Godot typed value (for property writes, method args). +## _serialize_value: Godot typed value → JSON-safe dict (for property reads, responses). +## _check_resource_paths: pre-coercion gate that validates Resource refs resolve. +## +## Both directions share a symmetric tag vocabulary — keep aligned or round-trip breaks. + +# Direct preload (not via _hub) to avoid circular dependency — _hub preloads us. +const _FileGuard := preload("res://addons/godot_mcp_toolkit/security/file_guard.gd") + + +static func coerce_value(value: Variant) -> Variant: + if typeof(value) == TYPE_ARRAY: + var result: Array = [] + for element in value: + result.append(coerce_value(element)) + return result + if typeof(value) != TYPE_DICTIONARY: + return value + match str(value.get("type", "")): + "Resource", "ResourceRef": + var resource_path := str(value.get("path", "")) + if resource_path.is_empty(): + # Fall through: {"type":"Resource","resource_type":"X"} → inline NewResource + var res_class := str(value.get("resource_type", value.get("class", ""))) + if not res_class.is_empty(): + return coerce_value({"type": "NewResource", "class": res_class, + "properties": value.get("properties", {})}) + return null + var guard := _FileGuard.resolve_safe(resource_path) + if guard["error"] != null: + return null + return ResourceLoader.load(resource_path, "", ResourceLoader.CACHE_MODE_REPLACE) + "NewResource": + var res_class := str(value.get("class", "")) + if res_class.is_empty() or not ClassDB.class_exists(res_class): + return null + if not ClassDB.is_parent_class(res_class, "Resource"): + return null + if not ClassDB.can_instantiate(res_class): + return null + var resource = ClassDB.instantiate(res_class) + if resource == null: + return null + var props: Dictionary = _safe_dict(value.get("properties", {})) + for key in props.keys(): + resource.set(str(key), coerce_value(props[key])) + return resource + "Vector2": + return Vector2( + float(value.get("x", 0.0)), + float(value.get("y", 0.0)), + ) + "Vector3": + return Vector3( + float(value.get("x", 0.0)), + float(value.get("y", 0.0)), + float(value.get("z", 0.0)), + ) + "Vector4": + return Vector4( + float(value.get("x", 0.0)), + float(value.get("y", 0.0)), + float(value.get("z", 0.0)), + float(value.get("w", 0.0)), + ) + "Vector2i": + return Vector2i(int(value.get("x", 0)), int(value.get("y", 0))) + "Vector3i": + return Vector3i( + int(value.get("x", 0)), + int(value.get("y", 0)), + int(value.get("z", 0)), + ) + "Color": + return Color( + float(value.get("r", 0.0)), + float(value.get("g", 0.0)), + float(value.get("b", 0.0)), + float(value.get("a", 1.0)), + ) + "Rect2": + return Rect2( + float(value.get("x", 0.0)), + float(value.get("y", 0.0)), + float(value.get("w", 0.0)), + float(value.get("h", 0.0)), + ) + "Rect2i": + return Rect2i( + int(value.get("x", 0)), + int(value.get("y", 0)), + int(value.get("w", 0)), + int(value.get("h", 0)), + ) + "Transform2D": + var x_axis: Dictionary = _safe_dict(value.get("x_axis", {})) + var y_axis: Dictionary = _safe_dict(value.get("y_axis", {})) + var origin_2d: Dictionary = _safe_dict(value.get("origin", {})) + return Transform2D( + Vector2(float(x_axis.get("x", 1.0)), float(x_axis.get("y", 0.0))), + Vector2(float(y_axis.get("x", 0.0)), float(y_axis.get("y", 1.0))), + Vector2(float(origin_2d.get("x", 0.0)), float(origin_2d.get("y", 0.0))), + ) + "Transform3D": + var basis_dict: Dictionary = _safe_dict(value.get("basis", {})) + var basis_x: Dictionary = _safe_dict(basis_dict.get("x", {})) + var basis_y: Dictionary = _safe_dict(basis_dict.get("y", {})) + var basis_z: Dictionary = _safe_dict(basis_dict.get("z", {})) + var origin_3d: Dictionary = _safe_dict(value.get("origin", {})) + var basis := Basis( + Vector3(float(basis_x.get("x", 1.0)), float(basis_x.get("y", 0.0)), float(basis_x.get("z", 0.0))), + Vector3(float(basis_y.get("x", 0.0)), float(basis_y.get("y", 1.0)), float(basis_y.get("z", 0.0))), + Vector3(float(basis_z.get("x", 0.0)), float(basis_z.get("y", 0.0)), float(basis_z.get("z", 1.0))), + ) + return Transform3D( + basis, + Vector3( + float(origin_3d.get("x", 0.0)), + float(origin_3d.get("y", 0.0)), + float(origin_3d.get("z", 0.0)), + ), + ) + "NodePath": + return NodePath(str(value.get("path", ""))) + "PackedVector2Array": + var arr := PackedVector2Array() + var elements = value.get("values", []) + if typeof(elements) == TYPE_ARRAY: + for el in elements: + var v = coerce_value(el) + if v is Vector2: + arr.append(v) + elif typeof(v) == TYPE_DICTIONARY and not (v as Dictionary).has("_coerce_error"): + arr.append(Vector2(float(v.get("x", 0.0)), float(v.get("y", 0.0)))) + else: + return {"_coerce_error": + "PackedVector2Array elements must be Vector2. Use: {type:'PackedVector2Array', values: [{type:'Vector2', x:0, y:0}, ...]}"} + return arr + "PackedVector3Array": + var arr := PackedVector3Array() + var elements = value.get("values", []) + if typeof(elements) == TYPE_ARRAY: + for el in elements: + var v = coerce_value(el) + if v is Vector3: + arr.append(v) + elif typeof(v) == TYPE_DICTIONARY and not (v as Dictionary).has("_coerce_error"): + arr.append(Vector3(float(v.get("x", 0.0)), float(v.get("y", 0.0)), float(v.get("z", 0.0)))) + else: + return {"_coerce_error": + "PackedVector3Array elements must be Vector3. Use: {type:'PackedVector3Array', values: [{type:'Vector3', x:0, y:0, z:0}, ...]}"} + return arr + "PackedColorArray": + var arr := PackedColorArray() + var elements = value.get("values", []) + if typeof(elements) == TYPE_ARRAY: + for el in elements: + var v = coerce_value(el) + if v is Color: + arr.append(v) + elif typeof(v) == TYPE_DICTIONARY and not (v as Dictionary).has("_coerce_error"): + arr.append(Color(float(v.get("r", 0.0)), float(v.get("g", 0.0)), + float(v.get("b", 0.0)), float(v.get("a", 1.0)))) + else: + return {"_coerce_error": + "PackedColorArray elements must be Color. Use: {type:'PackedColorArray', values: [{type:'Color', r:1, g:0, b:0, a:1}, ...]}"} + return arr + "LayerMask": + var lm_category := str(value.get("category", "2d_physics")) + return layers_to_mask(value.get("layers", []), lm_category) + _: + # Reject unknown type tags instead of silently passing through. + var type_tag := str(value.get("type", "")) + if not type_tag.is_empty(): + return {"_coerce_error": + "Unknown type tag '%s'. Supported: Vector2, Vector3, Vector4, Vector2i, Vector3i, Color, Rect2, Rect2i, Transform2D, Transform3D, NodePath, Resource, NewResource, PackedVector2Array, PackedVector3Array, PackedColorArray, LayerMask." % type_tag} + # No type key — generic dict with recursive coercion. + var result := {} + for k in value.keys(): + result[k] = coerce_value(value[k]) + return result + + +## Convert a layer-number value to a bitmask integer. +## Accepts: int (returned as-is), or Array of layer numbers (int) or +## named layers (String) → bitmask. Named layers are resolved via +## ProjectSettings (layer_names/{category}/layer_N). +static func layers_to_mask(value: Variant, category: String = "2d_physics") -> int: + if typeof(value) == TYPE_ARRAY: + var mask := 0 + for layer in value: + if typeof(layer) == TYPE_STRING: + var n := _resolve_layer_name(str(layer), category) + if n >= 1: + mask |= (1 << (n - 1)) + else: + var n := int(layer) + if n >= 1 and n <= 32: + mask |= (1 << (n - 1)) + return mask + return int(value) + + +## Resolve a named layer to its 1-based number by scanning ProjectSettings. +## Returns -1 if the name is not found. +static func _resolve_layer_name(layer_name: String, category: String) -> int: + for n in range(1, 33): + var key := "layer_names/%s/layer_%d" % [category, n] + if ProjectSettings.has_setting(key): + var stored: String = str(ProjectSettings.get_setting(key)) + if stored.to_lower() == layer_name.to_lower(): + return n + return -1 + + +## Project a {r,g,b,a} JSON dict onto a Color with opaque-white channel +## defaults (tint / modulate semantics). A non-dict value yields [param default] +## (itself opaque white unless overridden); within a dict, each absent channel +## falls to 1.0 so a partial {r,g,b} stays fully opaque. Distinct from the +## "Color" type tag above, whose channels default to opaque BLACK (paint +## semantics) — the two defaults must not be conflated. +static func color_from_dict(d: Variant, default: Color = Color(1, 1, 1, 1)) -> Color: + if typeof(d) != TYPE_DICTIONARY: + return default + return Color( + float(d.get("r", 1.0)), float(d.get("g", 1.0)), + float(d.get("b", 1.0)), float(d.get("a", 1.0))) + + +static func serialize_value(value: Variant) -> Variant: + match typeof(value): + TYPE_NIL: + return null + TYPE_BOOL, TYPE_INT, TYPE_FLOAT, TYPE_STRING: + return value + TYPE_VECTOR2: + return {"type": "Vector2", "x": value.x, "y": value.y} + TYPE_VECTOR3: + return {"type": "Vector3", "x": value.x, "y": value.y, "z": value.z} + TYPE_VECTOR4: + return {"type": "Vector4", "x": value.x, "y": value.y, "z": value.z, "w": value.w} + TYPE_VECTOR2I: + return {"type": "Vector2i", "x": value.x, "y": value.y} + TYPE_VECTOR3I: + return {"type": "Vector3i", "x": value.x, "y": value.y, "z": value.z} + TYPE_COLOR: + return {"type": "Color", "r": value.r, "g": value.g, "b": value.b, "a": value.a} + TYPE_RECT2: + return { + "type": "Rect2", + "x": value.position.x, "y": value.position.y, + "w": value.size.x, "h": value.size.y, + } + TYPE_RECT2I: + return { + "type": "Rect2i", + "x": value.position.x, "y": value.position.y, + "w": value.size.x, "h": value.size.y, + } + TYPE_TRANSFORM2D: + return { + "type": "Transform2D", + "x_axis": {"x": value.x.x, "y": value.x.y}, + "y_axis": {"x": value.y.x, "y": value.y.y}, + "origin": {"x": value.origin.x, "y": value.origin.y}, + } + TYPE_TRANSFORM3D: + return { + "type": "Transform3D", + "basis": { + "x": {"x": value.basis.x.x, "y": value.basis.x.y, "z": value.basis.x.z}, + "y": {"x": value.basis.y.x, "y": value.basis.y.y, "z": value.basis.y.z}, + "z": {"x": value.basis.z.x, "y": value.basis.z.y, "z": value.basis.z.z}, + }, + "origin": {"x": value.origin.x, "y": value.origin.y, "z": value.origin.z}, + } + TYPE_NODE_PATH: + return {"type": "NodePath", "path": str(value)} + TYPE_PACKED_VECTOR2_ARRAY: + # Emit the tagged form coerce_value parses back: each element is + # itself a tagged {type:"Vector2",x,y} dict (recurse so the round-trip + # is exact). Iterating a typed PackedVector2Array yields Vector2 elements. + var packed_v2: Array = [] + for element in value: + packed_v2.append(serialize_value(element)) + return {"type": "PackedVector2Array", "values": packed_v2} + TYPE_PACKED_VECTOR3_ARRAY: + var packed_v3: Array = [] + for element in value: + packed_v3.append(serialize_value(element)) + return {"type": "PackedVector3Array", "values": packed_v3} + TYPE_PACKED_COLOR_ARRAY: + var packed_col: Array = [] + for element in value: + packed_col.append(serialize_value(element)) + return {"type": "PackedColorArray", "values": packed_col} + TYPE_STRING_NAME: + return str(value) + TYPE_ARRAY: + var serialized_array: Array = [] + for element in value: + serialized_array.append(serialize_value(element)) + return serialized_array + TYPE_DICTIONARY: + var serialized_dictionary: Dictionary = {} + for key in value.keys(): + serialized_dictionary[str(key)] = serialize_value(value[key]) + return serialized_dictionary + TYPE_OBJECT: + if value == null: + return null + if value is Node: + return str((value as Node).get_path()) + if value is Resource: + var resource := value as Resource + return { + "type": "Resource", + "path": resource.resource_path, + "class": resource.get_class(), + } + return "" + _: + return var_to_str(value) + + +## Pre-coercion gate: recursively scan for {type:"Resource",path:...} entries +## and return the first path that fails FileGuard or ResourceLoader.load. +## Empty string means all Resource refs resolve. +static func check_resource_paths(value: Variant) -> String: + if typeof(value) == TYPE_DICTIONARY: + var vtype := str(value.get("type", "")) + if vtype == "Resource" or vtype == "ResourceRef": + var resource_path := str(value.get("path", "")) + if resource_path.is_empty(): + # Fall through: Resource with no path + class/resource_type → NewResource + var res_class := str(value.get("resource_type", value.get("class", ""))) + if not res_class.is_empty(): + return check_resource_paths({"type": "NewResource", "class": res_class, + "properties": value.get("properties", {})}) + return "" + var guard := _FileGuard.resolve_safe(resource_path) + if guard["error"] != null: + return resource_path + if ResourceLoader.load(resource_path) == null: + return resource_path + elif vtype == "NewResource": + var res_class := str(value.get("class", "")) + if res_class.is_empty() or not ClassDB.class_exists(res_class): + return "" % res_class + if not ClassDB.is_parent_class(res_class, "Resource"): + return "" % res_class + # Recurse into properties to check nested Resource refs + var props: Dictionary = value.get("properties", {}) if typeof(value.get("properties", null)) == TYPE_DICTIONARY else {} + for key in props.keys(): + var nested := check_resource_paths(props[key]) + if nested != "": + return nested + else: + # Recurse into untyped dict values for nested sub-resources + for key in value.keys(): + var nested := check_resource_paths(value[key]) + if nested != "": + return nested + return "" + if typeof(value) == TYPE_ARRAY: + for element in value: + var missing := check_resource_paths(element) + if missing != "": + return missing + return "" + + +## Coerce a JSON value using the existing property value as a type hint. +## When the JSON dict lacks a "type" key, injects the correct type tag +## inferred from the current Variant type so coerce_value() handles it. +## Falls back to plain coerce_value() when no inference is possible. +static func coerce_value_hint(raw: Variant, existing: Variant) -> Variant: + if typeof(raw) != TYPE_DICTIONARY or (raw as Dictionary).has("type"): + return coerce_value(raw) + var tag := "" + match typeof(existing): + TYPE_VECTOR2: tag = "Vector2" + TYPE_VECTOR3: tag = "Vector3" + TYPE_VECTOR4: tag = "Vector4" + TYPE_VECTOR2I: tag = "Vector2i" + TYPE_VECTOR3I: tag = "Vector3i" + TYPE_COLOR: tag = "Color" + TYPE_RECT2: tag = "Rect2" + TYPE_RECT2I: tag = "Rect2i" + if tag.is_empty(): + return coerce_value(raw) + var tagged := (raw as Dictionary).duplicate() + tagged["type"] = tag + return coerce_value(tagged) + + +static func _safe_dict(value: Variant) -> Dictionary: + return value if typeof(value) == TYPE_DICTIONARY else {} diff --git a/addons/godot_mcp_toolkit/contract/coerce.gd.uid b/addons/godot_mcp_toolkit/contract/coerce.gd.uid new file mode 100644 index 0000000..199093d --- /dev/null +++ b/addons/godot_mcp_toolkit/contract/coerce.gd.uid @@ -0,0 +1 @@ +uid://b2qx2vybe0mb2 diff --git a/addons/godot_mcp_toolkit/contract/execute_hints.gd b/addons/godot_mcp_toolkit/contract/execute_hints.gd new file mode 100644 index 0000000..8337846 --- /dev/null +++ b/addons/godot_mcp_toolkit/contract/execute_hints.gd @@ -0,0 +1,132 @@ +@tool +extends RefCounted +## Recovery hints appended to an execute_code failure message. +## +## execute_code evaluates a single GDScript expression through the engine +## Expression class. Expression resolves every bare identifier as a property +## lookup on the scope object (`self`) — it has no notion of global names — so a +## reference to an engine singleton, a project autoload, or a global class_name +## fails with "Invalid named index '' for base type Object", and load() +## cannot be called at all. These helpers turn those opaque engine strings into an +## actionable next step. Pure string logic; safe in both the editor and runtime +## handlers, which share it so the guidance stays identical. + +## Engine singletons that are reachable in normal GDScript but not through +## Expression. Named explicitly so the hint can call one out by name; any other +## unresolved global (an autoload, a global class) is caught by the generic +## bare-identifier branch instead. +const _ENGINE_SINGLETONS: Array[String] = [ + "EditorInterface", + "Engine", + "OS", + "Input", + "DisplayServer", + "ProjectSettings", + "ResourceLoader", + "ResourceSaver", + "RenderingServer", + "PhysicsServer2D", + "PhysicsServer3D", +] + + +## Returns the hint to append to [param err_text] (an [code]EXECUTE_FAILED[/code] +## message from a failed [code]Expression.execute()[/code]), given the original +## [param code] the caller ran. Empty when no known failure shape is recognised. +## +## Recognises three shapes, in priority order: an inaccessible bare global +## (engine singleton, autoload, or global class named at the start of an access +## chain), a [code]load()[/code] call (which Expression cannot make), and property +## access chained on a returned object. The leading-global check runs before the +## generic chained-access check because both surface as the same +## "Invalid named index … base type Object" string, and the global reading is the +## more accurate one when the failed name begins the expression. +static func build_hint(err_text: String, code: String) -> String: + var global_hint := _bare_global_hint(err_text, code) + if not global_hint.is_empty(): + return global_hint + var lower_err := err_text.to_lower() + if "'load'" in lower_err and ("call to" in lower_err or "function" in lower_err): + return _load_hint(code) + if "invalid named index" in lower_err and "base type object" in lower_err: + return ( + "\n\nHint: Expression.execute() cannot chain property access on returned objects. " + + "Use runtime_get_node_state or node_call_method for multi-step property access." + ) + return "" + + +## Hint for a bare global that Expression can't resolve, or [code]""[/code] when the +## failure is not that shape. [param err_text] is the failed message; [param code] +## is the expression, used to confirm the failed name is a leading global reference +## (not a property reached through a chain, which is a different, correctly-framed +## failure). Names the identifier and, when it is a known engine singleton, says so. +static func _bare_global_hint(err_text: String, code: String) -> String: + var re := RegEx.new() + re.compile("Invalid named index '([^']+)' for base type Object") + var m := re.search(err_text) + if m == null: + return "" + var identifier := m.get_string(1) + if not _is_leading_reference(code, identifier): + return "" + var kind := "a global singleton" if identifier in _ENGINE_SINGLETONS else "a global (autoload or global class)" + return ( + "\n\nHint: '%s' is %s, which is not accessible in execute_code. " % [identifier, kind] + + "Expression.execute() resolves a bare identifier as a property of the scope node, " + + "not as a global name, so no singleton, autoload, or class_name is reachable this way. " + + "Use the dedicated MCP tools instead — e.g. runtime_get_node_state or node_call_method " + + "to read a node's live state, project_get_settings for ProjectSettings, or (for a running " + + "game) drive the node whose script already references the global." + ) + + +## Whether [param name] appears in [param code] as a leading global reference — the +## first token, at the head of the expression or a sub-expression, not preceded by a +## [code].[/code] (which would make it a property reached through a chain). Anchors +## the match so [code]OS[/code] does not match [code]node.get_class_OS[/code]. +static func _is_leading_reference(code: String, name: String) -> bool: + var re := RegEx.new() + re.compile("(?:^|[^\\w.])%s\\b" % _escape_regex(name)) + return re.search(code) != null + + +## Escapes the RegEx metacharacters that a Godot identifier can never contain but a +## defensive caller should still not inject unescaped. Identifiers are +## [code][A-Za-z0-9_][/code] only, so this is belt-and-suspenders. +static func _escape_regex(text: String) -> String: + var out := "" + for chr in text: + if chr in "\\^$.|?*+()[]{}": + out += "\\" + out += chr + return out + + +## Hint for a [code]load()[/code] call, which Expression cannot make in any context. +## Both recovery paths are always offered — assigning a resource and running script +## logic — because either could be the caller's intent; the detected target only +## orders which leads. [param code] is the expression, scanned for the load() target. +static func _load_hint(code: String) -> String: + var re := RegEx.new() + re.compile("load\\s*\\(\\s*[\"']([^\"']+)[\"']\\s*\\)") + var m := re.search(code) + var script_first := m != null and m.get_string(1).ends_with(".gd") + var assign_remedy := ( + "To assign the resource, use node_set_property with " + + "{\"type\": \"Resource\", \"path\": \"res://...\"}." + ) + var script_remedy := ( + "To run GDScript logic in the editor: " + + "(1) write a @tool script with script_write, " + + "(2) create a temporary node with scene_create_node, " + + "(3) attach the script with node_set_script, " + + "(4) call the method with node_call_method, " + + "(5) delete the temp node with node_manage(action:'delete')." + ) + var lead := script_remedy if script_first else assign_remedy + var follow := assign_remedy if script_first else script_remedy + return ( + "\n\nHint: Expression.execute() cannot call load() in any context (editor or runtime). " + + lead + " " + follow + ) diff --git a/addons/godot_mcp_toolkit/contract/execute_hints.gd.uid b/addons/godot_mcp_toolkit/contract/execute_hints.gd.uid new file mode 100644 index 0000000..ab3e28e --- /dev/null +++ b/addons/godot_mcp_toolkit/contract/execute_hints.gd.uid @@ -0,0 +1 @@ +uid://dk1t7g5shxfej diff --git a/addons/godot_mcp_toolkit/contract/mcp_toolkit_command_options.gd b/addons/godot_mcp_toolkit/contract/mcp_toolkit_command_options.gd new file mode 100644 index 0000000..9722baf --- /dev/null +++ b/addons/godot_mcp_toolkit/contract/mcp_toolkit_command_options.gd @@ -0,0 +1,246 @@ +@tool +class_name MCPToolkitCommandOptions +extends RefCounted +## Fluent builder for the options a command declares when registered. +## +## Every chainable method returns [code]self[/code], so a registration reads as a +## single chain whose terminal [method to_dict] produces the metadata dictionary +## [method MCPToolkitCommandRegistry.add] consumes. Each setter records one facet +## of the command's contract: its hints ([method mark_read_only], +## [method mark_destructive], [method mark_idempotent]), its dispatch behaviour +## ([method mark_exclusive_execution], [method mark_scene_independent], +## [method with_timeout_ms]), its grouping ([method with_group]), its version range +## ([method with_min_godot_version] / [method with_max_godot_version]), and its +## path guards ([method guard_project_path] / [method guard_user_path]).[br] +## [br] +## For built-in tools, only [method mark_read_only] is load-bearing +## (routing/serialization); the client-visible readOnly/destructive/idempotent hints +## are server-authoritative (server [code]src/catalogue.ts[/code]). +## [method mark_destructive] / [method mark_idempotent] apply only to EXTENSION +## tools, whose annotations the bridge reads via +## [method MCPToolkitCommandRegistry.get_command_metadata]. Extension tools should +## use [MCPToolkitExtensionOptions], a subclass that requires a description at +## construction time (it errors on an empty one but still constructs).[br] +## [br] +## Example — register a read-only physics tool: +## [codeblock] +## registry.add("physics_list_bodies", _on_list_bodies, +## MCPToolkitCommandOptions.new() \ +## .mark_read_only() \ +## .with_timeout_ms(60000) \ +## .with_group("physics_tools", "Physics inspection", ["physics", "force"])) +## [/codeblock] + +var _description: String = "" +var _input_schema: Dictionary = {} +var _is_read_only: bool = false +var _is_destructive: bool = false +var _is_idempotent: bool = false +var _is_cancellable: bool = false +var _timeout_ms: int = 0 # 0 = use registry default (30000) +var _group_name: String = "" +var _group_description: String = "" +var _group_keywords: Array = [] +var _is_scene_independent: bool = false # inverted: true means NOT required +var _exclusive_execution: bool = false +var _min_godot_version: String = "" +var _max_godot_version: String = "" +var _success_hint: String = "" +var _path_guards: Dictionary = {} # param_name -> "project" | "user" + + +## Sets the human-readable [param description] the bridge advertises for an +## extension tool (built-in descriptions are server-authoritative). +## Returns [code]self[/code] for chaining. Prefer [MCPToolkitExtensionOptions], +## which takes the description in its constructor. +func with_description(description: String) -> MCPToolkitCommandOptions: + _description = description + return self + + +## Sets the JSON-Schema [param schema] describing the tool's parameters, used by +## the bridge to validate and advertise the tool's inputs. +## Returns [code]self[/code] for chaining. +func with_input_schema(schema: Dictionary) -> MCPToolkitCommandOptions: + _input_schema = schema + return self + + +## Sets the per-command watchdog deadline to [param timeout] milliseconds. +## A non-positive value (the default) means "use the registry default" (30 s); +## the registry floors a positive value at 1 s and caps it at 300 s. +## Returns [code]self[/code] for chaining. +func with_timeout_ms(timeout: int) -> MCPToolkitCommandOptions: + _timeout_ms = timeout + return self + + +## Assigns the command to the on-demand group [param name], so it loads only when +## that group is requested rather than at startup. [param description] and +## [param keywords] help the bridge present and surface the group. Pass an empty +## [param name] to leave the command ungrouped (eager). +## Returns [code]self[/code] for chaining. +func with_group(name: String, description: String = "", + keywords: Array = []) -> MCPToolkitCommandOptions: + _group_name = name + _group_description = description + _group_keywords = keywords + return self + + +## Marks the command read-only: it does not mutate editor or project state. +## This is load-bearing for every command — the dispatcher uses it to decide +## whether the call must be serialized against mutations (read-only calls bypass +## the mutation queue) — and for extension tools it also becomes the client-visible +## [code]readOnlyHint[/code]. Mutually exclusive with [method mark_destructive]. +## Returns [code]self[/code] for chaining. +func mark_read_only() -> MCPToolkitCommandOptions: + _is_read_only = true + return self + + +## Marks the command destructive (it may delete or overwrite data), surfacing the +## client-visible [code]destructiveHint[/code] for an extension tool. Applies to +## EXTENSION tools only — built-in hints are server-authoritative. The registry +## treats this as mutually exclusive with [method mark_read_only]. +## Returns [code]self[/code] for chaining. +func mark_destructive() -> MCPToolkitCommandOptions: + _is_destructive = true + return self + + +## Marks the command idempotent (repeating it with the same arguments has the same +## effect as calling it once), surfacing the client-visible +## [code]idempotentHint[/code] for an extension tool. Applies to EXTENSION tools +## only — built-in hints are server-authoritative. +## Returns [code]self[/code] for chaining. +func mark_idempotent() -> MCPToolkitCommandOptions: + _is_idempotent = true + return self + + +## Marks the command cancellable, so the dispatcher passes a +## [MCPToolkitToolContext] the handler can poll to abort cooperatively. +## Returns [code]self[/code] for chaining. +func mark_cancellable() -> MCPToolkitCommandOptions: + _is_cancellable = true + return self + + +## Marks the command as not requiring an open edited scene, so the dispatcher +## does not reject it when no scene is active. By default a command IS treated as +## requiring the active scene; call this for scene-independent tools (e.g. ones +## that only read project files). +## Returns [code]self[/code] for chaining. +func mark_scene_independent() -> MCPToolkitCommandOptions: + _is_scene_independent = true + return self + + +## Marks the command for exclusive execution, so the dispatcher serializes it +## against all other mutations even when it is also read-only (use for commands +## whose work must not overlap any other dispatch). +## Returns [code]self[/code] for chaining. +func mark_exclusive_execution() -> MCPToolkitCommandOptions: + _exclusive_execution = true + return self + + +## Restricts the command to a minimum engine version: when the running engine is +## older than [param version], the registry skips registration entirely so the +## command never appears. [param version] is [code]"major.minor"[/code] or +## [code]"major.minor.patch"[/code] (e.g. [code]"4.5"[/code]); an invalid format +## warns and is otherwise ignored. See [method with_max_godot_version] for the +## upper bound. Returns [code]self[/code] for chaining. +func with_min_godot_version(version: String) -> MCPToolkitCommandOptions: + if not _is_valid_version(version): + push_warning("[MCPToolkit] Invalid min_godot_version format: '%s' (expected 'major.minor', e.g. '4.5')" % version) + _min_godot_version = version + return self + + +## Restricts the command to a maximum engine version: when the running engine is +## newer than [param version], the registry skips registration. [param version] +## uses the same format as [method with_min_godot_version]; an invalid format +## warns and is ignored. Returns [code]self[/code] for chaining. +func with_max_godot_version(version: String) -> MCPToolkitCommandOptions: + if not _is_valid_version(version): + push_warning("[MCPToolkit] Invalid max_godot_version format: '%s' (expected 'major.minor', e.g. '4.6')" % version) + _max_godot_version = version + return self + + +## Sets the [param hint] auto-attached to a successful response when the handler +## did not set one itself, giving the caller follow-up guidance. +## Returns [code]self[/code] for chaining. +func with_success_hint(hint: String) -> MCPToolkitCommandOptions: + _success_hint = hint + return self + + +## Declares that the parameter named [param param] carries a [code]res://[/code] +## project path the dispatcher must validate (rejecting traversal/escape with +## [code]PATH_DENIED[/code]) BEFORE the handler runs. Use this for any LLM-supplied +## path an extension command reads or writes; built-in tools self-guard, so this is +## the declarative equivalent for extension commands. An absent or empty value at +## call time is left for the handler (it is not a rejection). See +## [method guard_user_path] for the [code]user://[/code] variant. +## Returns [code]self[/code] for chaining. +func guard_project_path(param: String) -> MCPToolkitCommandOptions: + _path_guards[param] = "project" + return self + + +## Like [method guard_project_path], but declares that the parameter named +## [param param] carries a [code]user://[/code] path. The dispatcher rejects +## traversal, non-[code]user://[/code] prefixes, and the plugin's own internal +## paths with a path-denied error before the handler runs. +## Returns [code]self[/code] for chaining. +func guard_user_path(param: String) -> MCPToolkitCommandOptions: + _path_guards[param] = "user" + return self + + +static func _is_valid_version(v: String) -> bool: + var parts := v.split(".") + if parts.size() < 2: + return false + return parts[0].is_valid_int() and parts[1].is_valid_int() + + +## Builds the options dictionary the registry stores for the command, collapsing +## every facet set on this builder into one metadata map. Keys with no meaningful +## value are omitted; [code]is_active_scene_required[/code] is the inverse of +## [method mark_scene_independent]. [method MCPToolkitCommandRegistry.add] calls +## this — you rarely call it directly. Returns the metadata dictionary. +func to_dict() -> Dictionary: + var d := { + "description": _description, + "is_read_only": _is_read_only, + "is_destructive": _is_destructive, + "is_idempotent": _is_idempotent, + "is_cancellable": _is_cancellable, + "is_active_scene_required": not _is_scene_independent, + } + if _exclusive_execution: + d["exclusive_execution"] = true + if not _input_schema.is_empty(): + d["input_schema"] = _input_schema + if _timeout_ms > 0: + d["timeout_ms"] = _timeout_ms + if _group_name != "": + var group := {"name": _group_name} + if _group_description != "": + group["description"] = _group_description + if not _group_keywords.is_empty(): + group["keywords"] = _group_keywords + d["group"] = group + if _min_godot_version != "": + d["min_godot_version"] = _min_godot_version + if _max_godot_version != "": + d["max_godot_version"] = _max_godot_version + if _success_hint != "": + d["success_hint"] = _success_hint + if not _path_guards.is_empty(): + d["path_guards"] = _path_guards + return d diff --git a/addons/godot_mcp_toolkit/contract/mcp_toolkit_command_options.gd.uid b/addons/godot_mcp_toolkit/contract/mcp_toolkit_command_options.gd.uid new file mode 100644 index 0000000..638cff1 --- /dev/null +++ b/addons/godot_mcp_toolkit/contract/mcp_toolkit_command_options.gd.uid @@ -0,0 +1 @@ +uid://cqtwpivq6grfw diff --git a/addons/godot_mcp_toolkit/contract/mcp_toolkit_error.gd b/addons/godot_mcp_toolkit/contract/mcp_toolkit_error.gd new file mode 100644 index 0000000..fe99637 --- /dev/null +++ b/addons/godot_mcp_toolkit/contract/mcp_toolkit_error.gd @@ -0,0 +1,255 @@ +@tool +class_name MCPToolkitError +extends RefCounted +## Shared MCP error contract — canonical error codes and failure envelope. +## +## Every command that fails returns a uniform dictionary built by [method fail]: +## [code]{"success": false, "error": , "code": }[/code], plus an +## optional [code]"hint"[/code] with recovery guidance. [constant CODES] is the +## authoritative vocabulary of [code]code[/code] values; [method require] is the +## shared "required parameters present" check; and [method guard_response_size] +## keeps an oversized response from exceeding the transport buffer. Mirrors +## [MCPToolkitSuccess], which builds the success envelope.[br] +## [br] +## Example: +## [codeblock] +## if node == null: +## return MCPToolkitError.fail("NODE_NOT_FOUND", "no node at " + path) +## [/codeblock] + +## The authoritative set of error [code]code[/code] values a response may carry. +## [method fail] asserts (in debug builds) that the code it is given appears here, +## so an undeclared code is caught as drift during development. +const CODES: Array[String] = [ + "ALREADY_EXISTS", + "ALREADY_PLAYING", + "BUSY", + "CLASS_MISMATCH", + "COMPILATION_FAILED", + "CONNECT_FAILED", + "CREATE_DIR_FAILED", + "DELETE_FAILED", + "DIR_NOT_EMPTY", + "DISCONNECTED", # Reserved — transport/peer drop; not currently emitted. + "EDITED_SCENE", + "EDITOR_VIEWPORT_UNAVAILABLE", + "EMPTY_CONTENT", + "EXECUTE_FAILED", + "EXTERNAL_EDITOR_ACTIVE", + "FAILED", + "FILE_TOO_LARGE", + "FILESYSTEM_NOT_READY", + "FOLDER_PROTECTED", + "GAME_NOT_RUNNING", + "HEADLESS_UNSUPPORTED", + "INTERNAL", + "INVALID_CLASS", + "INVALID_METHOD", + "INVALID_PARAMS", + "INVALID_PATH", + "INVALID_STATE", + "INVALID_VALUE", + "LOAD_FAILED", + "LOG_BUSY", + "LOG_UNAVAILABLE", + "NO_SCENE", + "NODE_NOT_FOUND", + "NOT_A_RESOURCE", + "NOT_BREAKED", + "NOT_FOUND", + "NOT_UNIQUE", + "PACK_FAILED", + "PARENT_NOT_FOUND", + "PARSE_ERROR", + "PATH_DENIED", + "PATH_IN_USE", + "PROPERTY_NOT_FOUND", + "READ_FAILED", + "RESPONSE_TOO_LARGE", + "RUNTIME_WINDOW_MINIMIZED", + "SAVE_DELETE_FAILED", + "SAVE_FAILED", + "SAVE_READ_FAILED", + "SAVE_WRITE_FAILED", + "SET_FAILED", + "TIMEOUT", + "UNKNOWN_CLASS", + "UNSUPPORTED", + "UNSUPPORTED_FILE_TYPE", + "WRITE_FAILED", +] + +## Recovery hint suggesting how to find a valid node path, for a call site that +## received an unresolvable one. Pass it as the [param hint] of [method fail]. +const HINT_NODE_PATH := "Use scene.get_tree to list valid node paths. Root node is always path '.'." + +## Recovery hint suggesting how to find a valid file path, for a call site that +## received an unresolvable one. Pass it as the [param hint] of [method fail]. +const HINT_FILE_PATH := "Use asset.list to search for files. Paths must start with res://" + +## Recovery hint suggesting how to find a valid class name, for a call site that +## received an unknown one. Pass it as the [param hint] of [method fail]. +const HINT_CLASS_NAME := "Use classdb.search to find valid class names." + +## Per-code default recovery hints, keyed by error code. When [method fail] is +## given an empty hint but the code has an entry here, that hint is auto-attached — +## so codes whose recovery advice never depends on call-site context carry it +## automatically. An explicit [param hint] passed to [method fail] wins over this. +## +## [code]LOG_BUSY[/code] / [code]LOG_UNAVAILABLE[/code] are deliberately absent: their +## recovery advice is version-gated ([code]source="buffer"[/code] is a real fallback only +## on Godot 4.5+), so every emit site passes [method log_busy_hint] / +## [method log_unavailable_hint] explicitly rather than relying on a default here. +const DEFAULT_HINTS := { + "TIMEOUT": "The editor may be busy. Try editor.wait_for_idle before retrying.", + "UNSUPPORTED": "Check COMPATIBILITY.md in the godot-mcp-toolkit repository for version requirements.", + "PATH_DENIED": "Paths must use res:// format. Example: res://scenes/main.tscn", + "PARENT_NOT_FOUND": "Parent directory does not exist. Use folder.create to create it first.", + "COMPILATION_FAILED": "The game failed to start due to script errors. Fix the errors shown above, then call game_start again. If no errors are shown, call editor_refresh to retrigger them, then editor_get_console for the full log.", + "GAME_NOT_RUNNING": "No running game detected. Use game.start first. If the MCP Runtime autoload is missing, re-enable the plugin in Project Settings.", + "RESPONSE_TOO_LARGE": "The response exceeded the WebSocket transport buffer and could not be delivered. Narrow the query (filter, fewer items, a smaller range) or paginate. If large responses are expected, raise mcp_toolkit/limits/ws_buffer_kb in Project Settings and reconnect.", +} + + +## Validates that every key listed in [param required] is present in +## [param parameters] as a non-empty string. Returns [code]null[/code] when all +## required keys are satisfied, or an [code]INVALID_PARAMS[/code] error dict (built +## by [method fail], with a path/class recovery hint for known keys) for the first +## missing one. Call it at the top of a handler and return its result if non-null. +static func require(parameters: Dictionary, required: Array) -> Variant: + for key in required: + var val = parameters.get(key, "") + if typeof(val) == TYPE_STRING and val.is_empty(): + var hint := "" + match key: + "file_path": + hint = HINT_FILE_PATH + "node_path": + hint = HINT_NODE_PATH + "path": + hint = "Provide a res:// folder path. Example: res://scenes/" + "class_name": + hint = HINT_CLASS_NAME + return fail("INVALID_PARAMS", "%s is required" % key, hint) + return null + + +## Builds a failure response. [param code] is the machine-readable error code +## (must be one of [constant CODES]); [param message] is the human-readable +## explanation. Returns +## [code]{"success": false, "error": message, "code": code}[/code]. When +## [param hint] is non-empty it is attached as [code]"hint"[/code]; otherwise, if +## [param code] has a [constant DEFAULT_HINTS] entry, that default hint is attached. +## This is the canonical way every handler reports an error.[br] +## [br] +## Example: +## [codeblock] +## return MCPToolkitError.fail("INVALID_PATH", "path must start with res://") +## [/codeblock] +static func fail(code: String, message: String, hint: String = "") -> Dictionary: + # Debug-only vocabulary guard: an emitted code absent from CODES is drift. + # Stripped from release builds, so it never affects the wire payload. + assert(code in CODES, "error code '%s' is not declared in MCPToolkitError.CODES" % code) + var result := {"success": false, "error": message, "code": code} + if hint != "": + result["hint"] = hint + elif DEFAULT_HINTS.has(code): + result["hint"] = DEFAULT_HINTS[code] + return result + + +## Recovery hint for a [code]LOG_BUSY[/code] failure — the log file exists but a read +## open failed. +## +## Pass the caller's [code]LogBuffer.uses_logger_api()[/code] as [param can_use_buffer] +## ([code]true[/code] on Godot 4.5+): only there is [code]source="buffer"[/code] a real +## file-independent fallback, so it is steered to only then; on 4.2–4.4 the buffer tails +## the same log and would fail identically, so the hint stays stop-game / retry. +## On Windows the dominant cause is a running game: the editor enables safe-save, so its +## READ open requests [code]_SH_DENYWR[/code] (deny-write), which the OS refuses while the +## play process holds the log open for writing — so the file source clears on game exit, +## while [code]source="buffer"[/code] (4.5+) and the in-game runtime reader are unaffected. +## With no game running, the rarer cause is an external holder (antivirus, file-sync, +## backup) briefly locking the file. Unreachable on POSIX (no mandatory share locking). +## Returns the string to pass as [method fail]'s [param hint]. +static func log_busy_hint(can_use_buffer: bool) -> String: + if can_use_buffer: + return "Use source=\"buffer\" (in-memory, no file I/O — reads even while a game runs). For the file source: on Windows a running game holds the log open, so game_stop then retry; with no game running, a backup/AV/sync tool may briefly hold it — retry shortly." + return "On Windows a running game holds the log file open — game_stop then read (source=\"buffer\" tails the same file here, so it won't bypass the lock). With no game running, retry shortly (a backup/AV/sync tool may briefly hold it)." + + +## Recovery hint for a [code]LOG_UNAVAILABLE[/code] failure — the log file could not be +## found or read. +## +## Pass the caller's [code]LogBuffer.uses_logger_api()[/code] as [param can_use_buffer] +## ([code]true[/code] on Godot 4.5+): only there does [code]source="buffer"[/code] work +## independently of file logging, so it is offered only then; on 4.2–4.4 the buffer reads +## the same file and depends on the same setting. Returns the string to pass as +## [method fail]'s [param hint]. +static func log_unavailable_hint(can_use_buffer: bool) -> String: + var hint := "log file could not be read — enable file logging in ProjectSettings → Debug → File Logging → Enable File Logging (debug/file_logging/enable_file_logging), then restart the editor" + if can_use_buffer: + return hint + ". Or use source=\"buffer\" (in-memory, real-time — no file needed)." + return hint + "." + + +## Tailored RESPONSE_TOO_LARGE hint for an oversized screenshot payload. A capture +## that overflows the buffer has a mode-specific escape hatch the generic hint lacks: +## re-request with [code]image_response_mode:"disk"[/code] to persist the PNG and +## receive only its path, or ask for a smaller size — so the guard swaps this in when +## the over-budget result carries an [code]image_base64[/code]. +const _IMAGE_RESPONSE_TOO_LARGE_HINT := "The captured image exceeded the WebSocket transport buffer. Retry with image_response_mode:\"disk\" to save the PNG to disk and receive only its file path, or request a smaller size. Raising mcp_toolkit/limits/ws_buffer_kb in Project Settings also works but requires a reconnect." + + +## Headroom reserved below max_bytes when guarding a response, in bytes. +## +## The native WS send rejects a frame WHOLESALE — never chunks — when +## `already-queued bytes + this payload > outbound_buffer_size` +## (godotengine/godot wsl_peer.cpp, ERR_OUT_OF_MEMORY; stable 4.2–4.5). The +## checked size is the raw UTF-8 payload, but the comparison also includes +## whatever a prior send has queued-but-not-yet-flushed to a slow peer, plus +## wslay's per-message framing bookkeeping. This margin absorbs that unseen +## in-flight slack so a response we judge "safe" still clears the buffer. +const _SIZE_GUARD_MARGIN := 4096 + + +## Returns the UTF-8 byte length of [param dict] once stringified to JSON — the +## unit the WebSocket send path measures (not character count). Pure; safe to call +## in both the editor and the runtime. +static func response_byte_size(dict: Dictionary) -> int: + return JSON.stringify(dict).to_utf8_buffer().size() + + +## Size-guards a fully-built JSON-RPC response against the peer's send buffer. +## +## Returns [param response] unchanged when it fits, or — when its UTF-8 byte +## length would exceed [param max_bytes] minus the framing margin — a size-safe +## replacement that preserves [code]jsonrpc[/code] + [code]id[/code] and swaps +## [code]result[/code] for a compact [code]RESPONSE_TOO_LARGE[/code] failure +## (carrying the recovery hint). The replacement is tiny by construction, so the +## caller can send it without re-checking.[br] +## [br] +## [param max_bytes] is the peer's outbound_buffer_size (captured at accept), so +## the guard works identically for the editor server +## ([code]mcp_toolkit/limits/ws_buffer_kb[/code]) and the runtime server +## (fixed 1 MB) with no ProjectSetting re-read here. +static func guard_response_size(response: Dictionary, max_bytes: int) -> Dictionary: + if max_bytes <= 0: + return response # No buffer cap configured — nothing to guard against. + if response_byte_size(response) <= max_bytes - _SIZE_GUARD_MARGIN: + return response + var safe := {} + if response.has("jsonrpc"): + safe["jsonrpc"] = response["jsonrpc"] + safe["id"] = response.get("id", null) + # An oversized screenshot payload gets the mode-specific escape hatch (persist to + # disk / smaller size); any other over-budget result keeps the generic narrow/ + # paginate advice. + var hint := DEFAULT_HINTS["RESPONSE_TOO_LARGE"] + var result_payload = response.get("result") + if typeof(result_payload) == TYPE_DICTIONARY and (result_payload as Dictionary).has("image_base64"): + hint = _IMAGE_RESPONSE_TOO_LARGE_HINT + safe["result"] = fail("RESPONSE_TOO_LARGE", + "response too large for the transport buffer (limit ~%d KB)" % int(max_bytes / 1024), + hint) + return safe diff --git a/addons/godot_mcp_toolkit/contract/mcp_toolkit_error.gd.uid b/addons/godot_mcp_toolkit/contract/mcp_toolkit_error.gd.uid new file mode 100644 index 0000000..998ee84 --- /dev/null +++ b/addons/godot_mcp_toolkit/contract/mcp_toolkit_error.gd.uid @@ -0,0 +1 @@ +uid://mtmqa50rua15 diff --git a/addons/godot_mcp_toolkit/contract/mcp_toolkit_success.gd b/addons/godot_mcp_toolkit/contract/mcp_toolkit_success.gd new file mode 100644 index 0000000..be66613 --- /dev/null +++ b/addons/godot_mcp_toolkit/contract/mcp_toolkit_success.gd @@ -0,0 +1,21 @@ +@tool +class_name MCPToolkitSuccess +extends RefCounted +## Success-response builder — symmetric with [method MCPToolkitError.fail]. +## +## [method ok] stamps every success response with [code]"success": true[/code], so +## a handler cannot accidentally omit the key the dispatch contract requires. +## Use it for every successful return, just as [MCPToolkitError] is +## used for every failure. + + +## Stamps [param data] with [code]"success": true[/code] and returns it as the +## success response. [param data] carries the handler's result payload (default an +## empty dict). The returned dictionary is [param data] itself, mutated in place. +## [codeblock] +## return MCPToolkitSuccess.ok({"value": 42}) +## # => {"value": 42, "success": true} +## [/codeblock] +static func ok(data: Dictionary = {}) -> Dictionary: + data["success"] = true + return data diff --git a/addons/godot_mcp_toolkit/contract/mcp_toolkit_success.gd.uid b/addons/godot_mcp_toolkit/contract/mcp_toolkit_success.gd.uid new file mode 100644 index 0000000..760d448 --- /dev/null +++ b/addons/godot_mcp_toolkit/contract/mcp_toolkit_success.gd.uid @@ -0,0 +1 @@ +uid://qgf0g7u5f2tb diff --git a/addons/godot_mcp_toolkit/contract/mcp_toolkit_tool_context.gd b/addons/godot_mcp_toolkit/contract/mcp_toolkit_tool_context.gd new file mode 100644 index 0000000..965ec85 --- /dev/null +++ b/addons/godot_mcp_toolkit/contract/mcp_toolkit_tool_context.gd @@ -0,0 +1,44 @@ +@tool +class_name MCPToolkitToolContext +extends RefCounted +## Per-invocation context for cancellable extension tool handlers. +## +## When a command is registered with +## [method MCPToolkitCommandOptions.mark_cancellable], the dispatcher passes an +## instance of this class as the handler's second argument. The handler observes +## cancellation cooperatively in one of two ways: reactively, by connecting to +## [signal cancelled] to abort a long-running operation; or by polling +## [method is_cancelled] between discrete steps. The dispatcher owns this object — +## do not store it beyond the handler's return, as it is scoped to a single tool +## invocation. + +## Emitted once when the invocation is cancelled, for handlers that want to react +## immediately rather than poll [method is_cancelled]. +signal cancelled + +var _cancelled := false + + +## Requests cancellation: flips the cancelled state and emits [signal cancelled] +## (idempotent — a second call is ignored). [b]Called by the dispatch system[/b], +## not by extension handlers; a handler observes cancellation through +## [method is_cancelled] or [signal cancelled] instead of calling this. +func cancel() -> void: + if _cancelled: + return + _cancelled = true + cancelled.emit() + + +## Returns [code]true[/code] once the invocation has been cancelled. Poll it +## between steps of a long operation and return early when it is set. +## [codeblock] +## func _on_long_tool(parameters, ctx): +## for item in big_list: +## if ctx.is_cancelled(): +## return MCPToolkitError.fail("FAILED", "cancelled") +## _process(item) +## return MCPToolkitSuccess.ok() +## [/codeblock] +func is_cancelled() -> bool: + return _cancelled diff --git a/addons/godot_mcp_toolkit/contract/mcp_toolkit_tool_context.gd.uid b/addons/godot_mcp_toolkit/contract/mcp_toolkit_tool_context.gd.uid new file mode 100644 index 0000000..a8789de --- /dev/null +++ b/addons/godot_mcp_toolkit/contract/mcp_toolkit_tool_context.gd.uid @@ -0,0 +1 @@ +uid://qeb3dft6g6gv diff --git a/addons/godot_mcp_toolkit/contract/pagination.gd b/addons/godot_mcp_toolkit/contract/pagination.gd new file mode 100644 index 0000000..24857b2 --- /dev/null +++ b/addons/godot_mcp_toolkit/contract/pagination.gd @@ -0,0 +1,122 @@ +@tool +extends RefCounted +## Shared builder for the self-describing pagination envelope. +## +## Every paginating tool returns one learnable shape so an LLM caller can page any +## result set the same way. This centralizes the envelope — the invariant fields, the +## resume-field arithmetic, and the hint-inclusion gate — so no command handler +## hand-rolls it and the vocabulary can never drift between tools. +## +## Two axes describe a page:[br] +## [b]Invariants[/b] — [code]returned[/code] (rows in this page), [code]total_[/code] +## (the full count past any cap), and [code]has_more[/code] — stamped on every page.[br] +## [b]Resume[/b] — either a linear resume field ([code]next_offset[/code] / +## [code]next_start_line[/code]) for resumable tools, or a documented cursor-less +## navigation (narrow filters / query a region) surfaced in the [code]hint[/code]. +## +## Reached as [code]Modules.Pagination[/code] from editor command handlers, and via a +## direct [code]preload[/code] alias from the runtime autoload (which cannot import the +## editor-tainted [code]Modules[/code] aggregator). This script names no editor-only +## symbols so it stays valid in an export template's parse of the runtime closure. +## +## The hint [i]wording[/i] is the caller's: each tool phrases its own resume/navigation +## sentence (kept byte-stable against its wire contract) and passes it in; this builder +## only decides whether to include it. The hint appears when [param has_more] is true or +## a clamp occurred (signalled by [code]limit_clamped[/code] in [param extras]). + + +## Stamps the pagination invariants onto a page dict and returns it. +## +## Adds [code]returned[/code], [code]total_[/code], and [code]has_more[/code] to +## [param page] (a dict the caller pre-shaped with its items and any echoed window +## params). When [param has_more] is true and [param resume_field] is non-empty, adds +## the resume field [param resume_field] set to [param resume_value]; a cursor-less tool +## passes an empty [param resume_field] and gets no resume field. Merges [param extras] +## verbatim (e.g. [code]limit_clamped[/code], a tool's echoed filters), then adds +## [param hint] when [param has_more] is true or [param extras] carries +## [code]limit_clamped[/code] and the hint is non-empty. +## +## [param unit] names the total the count describes (e.g. "matches", "classes", +## "bytes", "lines"). Returns [param page] itself, mutated in place, as a BARE dict — +## the caller wraps it in [MCPToolkitSuccess], merges it with context, or nests it. +## [codeblock] +## var page := Pagination.build({"nodes": rows, "offset": offset, "limit": limit}, +## "matches", total, rows.size(), offset + rows.size() < total, +## "next_offset", offset + rows.size(), +## "more matches remain — page with next_offset until has_more is false") +## return MCPToolkitSuccess.ok(page) +## [/codeblock] +static func build(page: Dictionary, unit: String, total: int, returned: int, + has_more: bool, resume_field: String, resume_value: int, hint: String, + extras: Dictionary = {}) -> Dictionary: + page["returned"] = returned + page["total_" + unit] = total + page["has_more"] = has_more + if has_more and resume_field != "": + page[resume_field] = resume_value + for key in extras.keys(): + page[key] = extras[key] + if (has_more or extras.has("limit_clamped")) and not hint.is_empty(): + page["hint"] = hint + return page + + +## LIST family — index-paginated pages (scene.query, classdb.search). +## +## Computes [code]returned[/code] from [param items] and [code]has_more[/code] from +## [param offset] against [param total], then stamps the invariants (and, when resumable +## and there is more, the [code]next_offset[/code] resume field) onto the caller-supplied +## [param page] via [method build]. The caller pre-shapes [param page] with its items and +## any echoed window params, so each tool keeps its own field set and order. [param unit] +## names the total (e.g. "matches", "classes"). [param hint] is the caller's composed +## resume/clamp sentence. +## +## Pass [param resumable] as false for a cursor-less LIST tool: [code]next_offset[/code] +## is suppressed and the caller's [param hint] carries the navigation guidance instead. +## Returns the bare page dict. +static func list_page(page: Dictionary, items: Array, offset: int, unit: String, + total: int, hint: String, extras: Dictionary = {}, + resumable: bool = true) -> Dictionary: + var returned := items.size() + var has_more := offset + returned < total + var resume_field := "next_offset" if resumable else "" + return build(page, unit, total, returned, has_more, resume_field, offset + returned, + hint, extras) + + +## CONTENT-byte family — byte-windowed pages (save.read). +## +## Computes [code]has_more[/code] from [param offset] and [param returned_bytes] against +## [param total_bytes], then stamps the invariants (unit "bytes") and the +## [code]next_offset[/code] byte resume field onto the caller-supplied [param page] via +## [method build]. [param returned_bytes] is the byte count in this window (which may +## differ from the content string length after decoding). The caller pre-shapes +## [param page] with its content and echoed [code]offset[/code]. [param hint] is the +## caller's composed resume sentence. +## +## Unlike [method list_page], [code]next_offset[/code] is emitted on every window (not only +## when there is more): a byte offset is always a valid resume point, so a paging caller +## can detect completion by [code]next_offset == total_bytes[/code]. Returns the bare page. +static func byte_page(page: Dictionary, offset: int, returned_bytes: int, + total_bytes: int, hint: String, extras: Dictionary = {}) -> Dictionary: + var next_offset := offset + returned_bytes + var has_more := next_offset < total_bytes + page["next_offset"] = next_offset + return build(page, "bytes", total_bytes, returned_bytes, has_more, + "", 0, hint, extras) + + +## CONTENT-line family — line-windowed pages (script.read, log readers). +## +## Computes [code]has_more[/code] as [param end_line] < [param total_lines], then stamps +## the invariants (unit "lines") and, when there is more, the [code]next_start_line[/code] +## resume field (1-based, [param end_line] + 1) onto the caller-supplied [param page] via +## [method build]. [param returned] is the line count in this window (pass explicitly — it +## need not equal [code]end_line - start_line + 1[/code] when the source clamps). The +## caller pre-shapes [param page] with its content and echoed line bounds. [param hint] is +## the caller's composed resume sentence. Returns the bare page dict. +static func line_page(page: Dictionary, end_line: int, returned: int, total_lines: int, + hint: String, extras: Dictionary = {}) -> Dictionary: + var has_more := end_line < total_lines + return build(page, "lines", total_lines, returned, has_more, + "next_start_line", end_line + 1, hint, extras) diff --git a/addons/godot_mcp_toolkit/contract/pagination.gd.uid b/addons/godot_mcp_toolkit/contract/pagination.gd.uid new file mode 100644 index 0000000..7491160 --- /dev/null +++ b/addons/godot_mcp_toolkit/contract/pagination.gd.uid @@ -0,0 +1 @@ +uid://boeroggj6ulo0 diff --git a/addons/godot_mcp_toolkit/contract/property_set_check.gd b/addons/godot_mcp_toolkit/contract/property_set_check.gd new file mode 100644 index 0000000..7651ade --- /dev/null +++ b/addons/godot_mcp_toolkit/contract/property_set_check.gd @@ -0,0 +1,158 @@ +@tool +extends RefCounted +## Pure, headless-testable detector for a silently-dropped property write. +## +## Godot's [method Object.set] is void and discards a Variant it cannot assign to +## the target property (a wrong type), so a write can "succeed" while the value +## never lands — a false success that sends an LLM caller chasing a value that was +## never stored. This module brackets a set with before/after readbacks and reports +## the drop. It is the single SSOT shared by every property-set path: the editor +## node/scene handlers (via [code]commands/editor_helpers.gd[/code]) and the runtime +## autoload's [code]runtime.set_property[/code] (via a direct preload). +## +## Runtime-clean by construction — it names only value types, [String] formatting, +## and [@GlobalScope] functions ([method @GlobalScope.type_string], +## [method @GlobalScope.is_equal_approx]), never an editor class, and preloads +## nothing. An editor symbol anywhere in this static graph would parse-fail the +## runtime autoload in an unstripped export (godotengine/godot#91713), and the +## runtime server preloads this file; keep it editor-free. + + +## Classify a property write as clean / accepted-but-adjusted / silently dropped. +## Godot's [method Object.set] is void: it silently discards a wrong-type Variant +## (a false success), but it also silently RESHAPES a convertible one (float 7.9 → +## int 7, "Foo/" → "Foo", a normalized direction) — accepted, yet the stored value +## differs from what was asked. Bracket a set with before/after readbacks and this +## returns a tri-state so the caller can succeed-clean, succeed-with-a-warning, or +## fail. Shared by the editor node/scene paths and the runtime autoload. +## [br] +## [param before] the property's value BEFORE the set (its type is the oracle) +## [param after] the property's value AFTER the set (read from the same place) +## [param coerced] the value that was assigned (already Coerce-d from JSON) +## [param path] property path, for the message +## [br] +## Returns a tri-state Dictionary: +## [code]{"status": "ok"}[/code] — stored ≈ requested (exact set, int→float where +## 5.0 == 5, set-to-same); clean success. +## [code]{"status": "adjusted", "warning": String}[/code] — the write was ACCEPTED +## but the engine reshaped it (truncate/sanitize/clamp/normalize) so the stored +## value ≠ the request; success + a warning naming property/requested/stored. +## [code]{"status": "dropped", "error": String}[/code] — a silent wrong-type drop +## (cross-family, value unchanged, ≠ request) → the caller emits SET_FAILED. +static func describe_set_drop( + before: Variant, after: Variant, coerced: Variant, path: String, +) -> Dictionary: + # Reported no error but nothing is there: the property is not on this object. The + # hint leads with the most common build-flow cause — a script-defined property set + # before its script is attached — then the mistyped-name and dedicated-API cases. + if after == null and coerced != null: + return {"status": "dropped", "error": + "set() on '%s' reported no error but readback is null. " % path + + "The property is not present on this object — most often it is defined " + + "by a script not attached to this node yet (attach the script first, " + + "then set its properties), a mistyped property name, or a property that " + + "needs a dedicated API (e.g. set_shader_parameter, add_animation_library)."} + # Stored value equals the request → clean success (exact, int→float 5.0==5, set-to-same). + if _values_loosely_equal(after, coerced): + return {"status": "ok"} + # Stored value ≠ requested. The convertible-family gate is the SINGLE arbiter of + # reshape-vs-drop on BOTH the changed- and unchanged-value paths: + # • same family → the engine legitimately reshaped an in-family value + # (truncate 7.9→7, sanitize "Foo/"→"Foo", normalize a direction) → ADJUSTED. + # • cross family → the engine did NOT store a real value. It either kept the old + # one (a plain drop, after == before) OR — for a bound setter like position / + # modulate — Variant-converted the wrong type to a ZERO and stored that, + # DESTROYING the prior value (after ≠ before). BOTH are discards, not reshapes. + # Gating on the family (not on after == before) is what catches this + # destructive-zero case that a non-zero prior exposes. + if before != null and _same_convertible_family(typeof(coerced), typeof(before)): + return {"status": "adjusted", "warning": _adjusted_warning(after, coerced, path)} + # Cross-family → the write did not land as a real value (kept-old or zeroed). + return {"status": "dropped", "error": + "property '%s' rejected the value: expected type %s, got %s (%s). " + % [path, type_string(typeof(before)), type_string(typeof(coerced)), + _brief_set_value(coerced)] + + "Godot's set() silently discards a wrong-type value. Pass a value of the " + + "expected type — for struct types use the tagged form " + + "(e.g. {\"type\":\"Vector2\",\"x\":0,\"y\":0})."} + + +## Warning text for an accepted-but-adjusted write, naming what was stored vs asked. +static func _adjusted_warning(after: Variant, coerced: Variant, path: String) -> String: + return ("note: '%s' stored %s but you requested %s — the engine adjusted the value to fit %s." + % [path, _brief_set_value(after), _brief_set_value(coerced), type_string(typeof(after))]) + + +## True when assigning type [param t_coerced] onto a property of type +## [param t_before] stays within one convertible family Godot's set() accepts and +## reshapes in place: the exact same type (a value type may normalize/transform on +## set), both numeric (bool/int/float — the engine truncates float→int etc.), or +## both stringy (String/StringName/NodePath — normalized/sanitized). OBJECT is +## excluded — Resource assignment has no in-family reshape, so a wrong-subtype drop +## must reach the identity backstop rather than being trusted here. +static func _same_convertible_family(t_coerced: int, t_before: int) -> bool: + if t_coerced == TYPE_OBJECT or t_before == TYPE_OBJECT: + return false + if t_coerced == t_before: + return true + if _is_numeric_type(t_coerced) and _is_numeric_type(t_before): + return true + if _is_stringy_type(t_coerced) and _is_stringy_type(t_before): + return true + # Integer/float vector variants convert too (Vector2 7.9 → Vector2i 7), mirroring + # _values_loosely_equal so a truncate-onto-current here reads as adjusted, not dropped. + if (t_coerced == TYPE_VECTOR2 or t_coerced == TYPE_VECTOR2I) \ + and (t_before == TYPE_VECTOR2 or t_before == TYPE_VECTOR2I): + return true + if (t_coerced == TYPE_VECTOR3 or t_coerced == TYPE_VECTOR3I) \ + and (t_before == TYPE_VECTOR3 or t_before == TYPE_VECTOR3I): + return true + return false + + +## True when [param t] is a numeric Variant type. bool counts — the engine assigns +## it as 0/1 in a numeric slot, so a bool↔int/float write is a real conversion. +static func _is_numeric_type(t: int) -> bool: + return t == TYPE_BOOL or t == TYPE_INT or t == TYPE_FLOAT + + +## True when [param t] is a string-like Variant type. String, StringName and +## NodePath are mutually assignable in the engine (set() converts between them). +static func _is_stringy_type(t: int) -> bool: + return t == TYPE_STRING or t == TYPE_STRING_NAME or t == TYPE_NODE_PATH + + +## Cross-type-tolerant value equality used to judge whether a write landed. Same +## type → exact [code]==[/code]. Within the numeric family (bool/int/float) and the +## string family (String/StringName/NodePath) values compare across the family, and +## integer/float vector variants (Vector2i↔Vector2, Vector3i↔Vector3) compare +## approximately — each mirrors a conversion Godot's set() performs. Anything else +## across types is unequal. +static func _values_loosely_equal(a: Variant, b: Variant) -> bool: + var ta := typeof(a) + var tb := typeof(b) + if ta == tb: + return a == b + if _is_numeric_type(ta) and _is_numeric_type(tb): + return is_equal_approx(float(a), float(b)) + if _is_stringy_type(ta) and _is_stringy_type(tb): + return str(a) == str(b) + if (ta == TYPE_VECTOR2 or ta == TYPE_VECTOR2I) \ + and (tb == TYPE_VECTOR2 or tb == TYPE_VECTOR2I): + var v2a: Vector2 = type_convert(a, TYPE_VECTOR2) + var v2b: Vector2 = type_convert(b, TYPE_VECTOR2) + return v2a.is_equal_approx(v2b) + if (ta == TYPE_VECTOR3 or ta == TYPE_VECTOR3I) \ + and (tb == TYPE_VECTOR3 or tb == TYPE_VECTOR3I): + var v3a: Vector3 = type_convert(a, TYPE_VECTOR3) + var v3b: Vector3 = type_convert(b, TYPE_VECTOR3) + return v3a.is_equal_approx(v3b) + return false + + +## Short, safe rendering of a value for an error message (truncated at 60 chars). +static func _brief_set_value(value: Variant) -> String: + var s := var_to_str(value) + if s.length() > 60: + return s.left(57) + "..." + return s diff --git a/addons/godot_mcp_toolkit/contract/property_set_check.gd.uid b/addons/godot_mcp_toolkit/contract/property_set_check.gd.uid new file mode 100644 index 0000000..029a0d6 --- /dev/null +++ b/addons/godot_mcp_toolkit/contract/property_set_check.gd.uid @@ -0,0 +1 @@ +uid://docknc8r0uy54 diff --git a/addons/godot_mcp_toolkit/contract/screenshot_response.gd b/addons/godot_mcp_toolkit/contract/screenshot_response.gd new file mode 100644 index 0000000..ad95f39 --- /dev/null +++ b/addons/godot_mcp_toolkit/contract/screenshot_response.gd @@ -0,0 +1,256 @@ +@tool +extends RefCounted +## Shared screenshot response builder — mode parse, [code]image_detail[/code] sizing, +## save-path guard, disk persist, and inline/disk/both payload shaping for the three +## screenshot handlers. +## +## The [code]image_response_mode[/code] param selects how a capture is returned: +## [code]"inline"[/code] (default) embeds the base64 PNG in the response; +## [code]"disk"[/code] persists the PNG and returns only its file path (for very +## large captures or to conserve context tokens); [code]"both"[/code] does both. +## A [code]save_path[/code] names the destination (validated against the caller's +## allowlist, [code].png[/code] required); when omitted in disk/both an auto-named +## file lands under [code]user://screenshots/[/code]. The editor and runtime +## handlers share this one builder so the wire shape stays identical across all +## three call sites.[br] +## [br] +## The [code]image_detail[/code] param ([code]"full"[/code]/[code]"mid"[/code]/ +## [code]"low"[/code]) caps the [b]inline[/b] image's long edge; this file owns the +## pure [method image_detail_dims] calculator (the capture handler applies the +## [Image] resize — this stays a pure shaper) and echoes the applied level plus the +## returned [code]"WxH"[/code] so a size reduction is never silent. A disk/both +## response also carries a hint that the saved file is full resolution.[br] +## [br] +## Runtime-clean by construction — it preloads only [code]security/file_guard.gd[/code] +## and names value types plus [MCPToolkitError] (a runtime-safe global class), +## [FileAccess], [DirAccess], [ProjectSettings], [Marshalls], [Image] (a core class), +## and [Time], never an editor class. An editor symbol anywhere in this static graph +## would parse-fail the runtime autoload in an unstripped export +## (godotengine/godot#91713), and the runtime server preloads this file; keep it +## editor-free. + +const FileGuard := preload("res://addons/godot_mcp_toolkit/security/file_guard.gd") + +## Directory auto-named captures are written to when disk/both mode omits a save_path. +const _AUTO_NAME_DIR := "user://screenshots/" + +## Long-edge cap (px) per image_detail level for the inline image. Provisional +## targets: "mid" keeps HUD text readable, "low" preserves gross layout/motion only; +## "full" is absent here (never caps). Tuned values pending a live fidelity check. +const IMAGE_DETAIL_CAPS := {"mid": 1024, "low": 512} + + +## Returns the requested response mode: [code]"inline"[/code], [code]"disk"[/code], +## or [code]"both"[/code]. +## +## Absent [code]image_response_mode[/code] defaults to [code]"inline"[/code]. A +## present-but-unrecognized value returns [code]""[/code] so the caller can reject +## it as INVALID_PARAMS. [param parameters] is the raw JSON-RPC parameter dict. +static func mode_of(parameters: Dictionary) -> String: + if not parameters.has("image_response_mode"): + return "inline" + var mode := str(parameters.get("image_response_mode", "")) + if mode == "inline" or mode == "disk" or mode == "both": + return mode + return "" + + +## Returns the requested inline-resolution level: [code]"full"[/code], +## [code]"mid"[/code], or [code]"low"[/code]. +## +## Absent [code]image_detail[/code] defaults to [code]"full"[/code] (native, no cap). +## A present-but-unrecognized value returns [code]""[/code] so each capture handler +## can reject it as INVALID_PARAMS rather than silently falling through to full-res. +## [param parameters] is the raw JSON-RPC parameter dict. +static func detail_of(parameters: Dictionary) -> String: + if not parameters.has("image_detail"): + return "full" + var detail := str(parameters.get("image_detail", "")) + if detail == "full" or detail == "mid" or detail == "low": + return detail + return "" + + +## Target inline dimensions for [param detail] applied to a native +## [param width]×[param height] frame. +## +## Proportional, aspect-preserving, and shrink-only: returns the native dims +## unchanged for [code]"full"[/code], for an unknown value, or when the long edge is +## already within the level's cap (never upscales). The reduced levels cap the long +## edge to [constant IMAGE_DETAIL_CAPS] and scale the short edge to match. Pure and +## deterministic — the sizing decision lives here so it is unit-testable without an +## editor, while the capture handler owns the [method Image.resize] call. +static func image_detail_dims(width: int, height: int, detail: String) -> Vector2i: + if not IMAGE_DETAIL_CAPS.has(detail): + return Vector2i(width, height) + var cap: int = IMAGE_DETAIL_CAPS[detail] + var long_edge := maxi(width, height) + if long_edge <= cap: + return Vector2i(width, height) + var scale := float(cap) / float(long_edge) + return Vector2i(maxi(1, roundi(width * scale)), maxi(1, roundi(height * scale))) + + +## Shapes the screenshot response for the requested mode, persisting the PNG when +## disk/both, and returns the data Dictionary the handler wraps in +## [method MCPToolkitSuccess.ok]. +## +## [param parameters] is the raw parameter dict (read for [code]image_response_mode[/code], +## [code]image_detail[/code], and [code]save_path[/code]); [param png_bytes] is the +## [b]full-res[/b] encoded PNG and [param width] / [param height] its native +## dimensions — always what a disk copy persists; [param allowed_save_prefixes] is +## the save-path allowlist for this context (editor: [code]res://[/code] + +## [code]user://screenshots/[/code]; runtime: [code]user://screenshots/[/code] only).[br] +## [br] +## The [b]inline[/b] image is separable so [code]image_detail[/code] can downscale it +## while disk stays full-res: pass a pre-downscaled [param inline_png_bytes] + +## [param inline_width] / [param inline_height] and the inline base64 uses those +## instead of the full-res buffer. Left at the defaults (empty / [code]-1[/code]), the +## inline reuses the full-res buffer — the [code]"full"[/code] path, byte-identical to +## a pre-[code]image_detail[/code] capture. The caller (not this pure shaper) owns the +## [method Image.resize]. Every payload echoes the applied [code]image_detail[/code] +## plus [code]returned[/code] ([code]"WxH"[/code] of the returned inline, or of the +## full-res file in disk mode) so a size reduction is never silent.[br] +## [br] +## A [code]save_path[/code] is validated whenever present, in ANY mode: it must pass +## [method FileGuard.resolve_safe] against [param allowed_save_prefixes] and end in +## [code].png[/code]. In inline mode it is validated but NOT persisted (the response +## stays byte-identical to a no-save capture). In disk/both mode the target is the +## given [code]save_path[/code], else an auto-named file under +## [code]user://screenshots/[/code]; the directory is created and the full-res PNG +## written, and a [code]hint[/code] discloses that the saved file is full resolution.[br] +## [br] +## Returns the shaped payload on success: +## [code]{image_base64, mime_type, width, height, bytes, image_detail, returned}[/code] +## for inline; [code]{path, width, height, bytes, mime_type, image_detail, returned, +## hint}[/code] (no base64) for disk; the inline shape plus [code]path[/code] and +## [code]hint[/code] for both. A returned [code]path[/code] is the globalized absolute +## file path. On failure it returns a [method MCPToolkitError.fail] dict directly +## (identified by [code]"success": false[/code]): INVALID_PARAMS (bad mode value or +## non-[code].png[/code] save_path), PATH_DENIED (FileGuard rejection), or INTERNAL +## (mkdir / write failure). +static func build(parameters: Dictionary, png_bytes: PackedByteArray, width: int, height: int, + allowed_save_prefixes: Array, inline_png_bytes: PackedByteArray = PackedByteArray(), + inline_width: int = -1, inline_height: int = -1) -> Dictionary: + var mode := mode_of(parameters) + if mode.is_empty(): + return MCPToolkitError.fail("INVALID_PARAMS", + "image_response_mode must be one of [inline, disk, both] (got %s)" + % str(parameters.get("image_response_mode", ""))) + + var save_path := str(parameters.get("save_path", "")) + # The applied inline-resolution level, echoed on every shape. The handler has + # already validated the enum; absent → "full" (native). Disk mode returns no inline + # image, so its "returned" reports the full-res dims regardless of this value. + var detail := str(parameters.get("image_detail", "full")) + + # The inline image is the pre-downscaled buffer when the caller supplied one, else + # the full-res buffer (the "full" path). Disk mode ignores these — no inline. + var has_inline_override := inline_png_bytes.size() > 0 and inline_width >= 0 and inline_height >= 0 + var inline_bytes := inline_png_bytes if has_inline_override else png_bytes + var inline_w := inline_width if has_inline_override else width + var inline_h := inline_height if has_inline_override else height + + # Validate a supplied save_path in every mode, so inline callers get the same + # deterministic rejection they would in disk/both rather than a silent accept. + if not save_path.is_empty(): + var validation := _validate_save_path(save_path, allowed_save_prefixes) + if validation.get("success") == false: + return validation + + if mode == "inline": + return _inline_payload(inline_bytes, inline_w, inline_h, detail) + + # disk / both: resolve the destination, ensure the directory, write the full-res PNG. + var target := save_path if not save_path.is_empty() else _auto_name() + var persisted := _persist_png(target, png_bytes) + if persisted.get("success") == false: + return persisted + var globalized := str(persisted["path"]) + + if mode == "both": + var payload := _inline_payload(inline_bytes, inline_w, inline_h, detail) + payload["path"] = globalized + payload["hint"] = _full_res_hint(globalized) + return payload + # Disk-only: the returned artifact is the full-res file, so "returned" = full-res dims. + return _disk_payload(globalized, png_bytes, width, height, detail) + + +## Validates [param save_path] against [param allowed_prefixes] via FileGuard plus a +## [code].png[/code]-suffix check. Returns [code]{}[/code] when valid, else the +## PATH_DENIED / INVALID_PARAMS [method MCPToolkitError.fail] dict. +static func _validate_save_path(save_path: String, allowed_prefixes: Array) -> Dictionary: + var guard := FileGuard.resolve_safe(save_path, allowed_prefixes) + if guard["error"] != null: + return MCPToolkitError.fail("PATH_DENIED", str(guard["reason"])) + if not save_path.ends_with(".png"): + return MCPToolkitError.fail("INVALID_PARAMS", + "save_path must end with .png: %s" % save_path) + return {} + + +## Writes [param png_bytes] to [param target] (a validated res:// or user:// path), +## creating the parent directory. Returns [code]{"path": }[/code] +## on success, else an INTERNAL [method MCPToolkitError.fail] dict. +static func _persist_png(target: String, png_bytes: PackedByteArray) -> Dictionary: + var directory_path := target.get_base_dir() + if not directory_path.is_empty(): + var mkdir_error := DirAccess.make_dir_recursive_absolute(directory_path) + if mkdir_error != OK and mkdir_error != ERR_ALREADY_EXISTS: + return MCPToolkitError.fail("INTERNAL", + "could not create %s (err %d)" % [directory_path, mkdir_error]) + var file := FileAccess.open(target, FileAccess.WRITE) + if file == null: + return MCPToolkitError.fail("INTERNAL", + "could not open %s for writing (err %d)" % [target, FileAccess.get_open_error()]) + file.store_buffer(png_bytes) + file.close() + return {"path": ProjectSettings.globalize_path(target)} + + +## The inline payload: the base64 PNG plus its metadata and the applied-detail +## disclosure, with the historical keys first and the two disclosure keys appended. +static func _inline_payload(png_bytes: PackedByteArray, width: int, height: int, + detail: String) -> Dictionary: + return { + "image_base64": Marshalls.raw_to_base64(png_bytes), + "mime_type": "image/png", + "width": width, + "height": height, + "bytes": png_bytes.size(), + "image_detail": detail, + "returned": "%dx%d" % [width, height], + } + + +## The lean disk payload: the file path plus metadata and the applied-detail +## disclosure, with NO embedded base64. [param width] / [param height] are the +## full-res dims of the saved file (disk mode never downscales). +static func _disk_payload(globalized_path: String, png_bytes: PackedByteArray, + width: int, height: int, detail: String) -> Dictionary: + return { + "path": globalized_path, + "width": width, + "height": height, + "bytes": png_bytes.size(), + "mime_type": "image/png", + "image_detail": detail, + "returned": "%dx%d" % [width, height], + "hint": _full_res_hint(globalized_path), + } + + +## The disk-persist disclosure hint: the saved file is always full resolution +## regardless of [code]image_detail[/code], so an agent can read it for pixel detail +## instead of re-capturing. [param globalized_path] is the saved file's absolute path. +static func _full_res_hint(globalized_path: String) -> String: + return "Saved full-res → %s. Read it for pixel detail without re-capturing." % globalized_path + + +## A unique auto-name under [code]user://screenshots/[/code] for a save-less +## disk/both capture. Timestamp for human ordering plus a microsecond tick so two +## captures in the same second never collide. +static func _auto_name() -> String: + var stamp := Time.get_datetime_string_from_system(false, false).replace(":", "-") + return "%sscreenshot_%s_%d.png" % [_AUTO_NAME_DIR, stamp, Time.get_ticks_usec()] diff --git a/addons/godot_mcp_toolkit/contract/screenshot_response.gd.uid b/addons/godot_mcp_toolkit/contract/screenshot_response.gd.uid new file mode 100644 index 0000000..3e328a2 --- /dev/null +++ b/addons/godot_mcp_toolkit/contract/screenshot_response.gd.uid @@ -0,0 +1 @@ +uid://vf5x0ohl46rm diff --git a/addons/godot_mcp_toolkit/core/autoload_identity.gd b/addons/godot_mcp_toolkit/core/autoload_identity.gd new file mode 100644 index 0000000..577f96e --- /dev/null +++ b/addons/godot_mcp_toolkit/core/autoload_identity.gd @@ -0,0 +1,34 @@ +@tool +extends RefCounted +## Single source of truth for the toolkit's runtime autoload identity — the +## [name, res:// path] pair(s) the plugin must register, plus the pure derivation +## of the ProjectSettings key/value each pair maps to. +## +## A pure-const leaf: it preloads nothing and holds no behavior beyond derivation. +## It exists so the plugin-lifecycle domain (registration + on-load self-heal) and +## the export-strip domain (null-for-bake, restore-after) share one identity without +## coupling to each other — each preloads this leaf, neither preloads the other, so a +## rename or path change lands in exactly one place. No [code]class_name[/code]: +## consumers reach it as [code]const AutoloadIdentity := preload(...)[/code]. + +## Mode B — the runtime autoload that hosts the game-side WebSocket server. The +## enabled plugin guarantees this autoload is registered; registration is idempotent, +## so a project.godot that already carries the entry keeps its existing value. +const RUNTIME_AUTOLOAD_NAME := "MCPRuntimeServer" +const RUNTIME_AUTOLOAD_PATH := "res://addons/godot_mcp_toolkit/runtime/mcp_runtime_server.gd" + +## The autoloads the enabled plugin must guarantee, as [name, res:// path] pairs. +## One entry today (the runtime server); the list shape future-proofs a second. +const REQUIRED_AUTOLOADS := [[RUNTIME_AUTOLOAD_NAME, RUNTIME_AUTOLOAD_PATH]] + + +## The ProjectSettings key for an autoload — [code]"autoload/"[/code]. +## [param autoload_name] is the singleton name (e.g. "MCPRuntimeServer"). +static func settings_key(autoload_name: String) -> String: + return "autoload/" + autoload_name + + +## The ProjectSettings value for an autoload script — [code]"*"[/code]. The +## leading "*" marks the singleton enabled, matching how the editor writes autoloads. +static func settings_value(script_path: String) -> String: + return "*" + script_path diff --git a/addons/godot_mcp_toolkit/core/autoload_identity.gd.uid b/addons/godot_mcp_toolkit/core/autoload_identity.gd.uid new file mode 100644 index 0000000..eb56f4c --- /dev/null +++ b/addons/godot_mcp_toolkit/core/autoload_identity.gd.uid @@ -0,0 +1 @@ +uid://c412mov2bobk0 diff --git a/addons/godot_mcp_toolkit/core/autoload_registration.gd b/addons/godot_mcp_toolkit/core/autoload_registration.gd new file mode 100644 index 0000000..99ad24b --- /dev/null +++ b/addons/godot_mcp_toolkit/core/autoload_registration.gd @@ -0,0 +1,79 @@ +@tool +extends RefCounted +## Registers and removes the toolkit's runtime autoload(s) in ProjectSettings — the +## shared path behind both first-enable and the on-load self-heal, so the two can +## never diverge. +## +## Writes autoloads with ProjectSettings.set_setting + save rather than +## EditorPlugin.add_autoload_singleton, on purpose. add_autoload_singleton updates +## only the editor's in-memory state and pushes a startup undo entry, whereas the +## game reads project.godot at F5: the direct path persists to disk by construction +## (surviving to the build) and leaves no undo entry (a stray Ctrl+Z can't undo the +## registration). A has_setting guard means a present value is never clobbered, so a +## healthy project takes no write and shows no project.godot diff. Removal stays +## asymmetric — [method unregister] uses remove_autoload_singleton, a genuine editor +## action with no disk-persistence need (the concern is restoring an absent autoload, +## not undoing the enable toggle). +## +## Editor-only: takes an EditorPlugin and drives ProjectSettings; it is preloaded +## only by the editor-only plugin.gd, never by the runtime autoload. The autoload +## identity (name/path pairs + key/value derivation) comes from the shared +## autoload_identity leaf so the export-strip domain reads the same source without +## coupling to this module. No [code]class_name[/code]: consumers reach it as +## [code]const AutoloadRegistration := preload(...)[/code]. + +const Identity := preload("res://addons/godot_mcp_toolkit/core/autoload_identity.gd") + + +## Guarantee every required autoload is present in ProjectSettings, writing only what +## is missing. +## +## Probes each required entry, writes the absent ones with set_setting, then — once +## after the loop and only if something was written — save()s (persists for the next +## F5) and emits settings_changed (refreshes the editor's in-memory autoload view +## after the disk write). Callers that must avoid a premature settings_changed poke +## should invoke this before wiring their own settings_changed listeners. +static func ensure_registered() -> void: + var present: PackedStringArray = PackedStringArray() + for entry in Identity.REQUIRED_AUTOLOADS: + var autoload_name: String = entry[0] + var key := Identity.settings_key(autoload_name) + if ProjectSettings.has_setting(key): + present.append(key) + + var missing: Array = compute_missing(present, Identity.REQUIRED_AUTOLOADS) + for entry in missing: + var autoload_name: String = entry[0] + var script_path: String = entry[1] + ProjectSettings.set_setting( + Identity.settings_key(autoload_name), Identity.settings_value(script_path)) + + if not missing.is_empty(): + ProjectSettings.save() + ProjectSettings.emit_signal("settings_changed") + + +## Remove the required autoload(s) via [param plugin]'s remove_autoload_singleton. +## The teardown counterpart of the enable path; removal is a genuine editor action +## with no disk-persistence need, hence the deliberate asymmetry with registration. +static func unregister(plugin: EditorPlugin) -> void: + for entry in Identity.REQUIRED_AUTOLOADS: + var autoload_name: String = entry[0] + plugin.remove_autoload_singleton(autoload_name) + + +## Pure decision: of the [name, path] pairs in [param required], return those whose +## "autoload/" key is absent from [param present] (the already-probed set of +## present autoload keys). +## +## Plain-data-in, plain-data-out — the side-effecting shell does the ProjectSettings +## probing and writing, so this stays unit-testable headless. Kept a plain static +## (no Callable) so a bare-static-method reference can't hit Godot 4.2's NIL-self +## bind on an unbound static Callable. +static func compute_missing(present: PackedStringArray, required: Array) -> Array: + var missing: Array = [] + for entry in required: + var autoload_name: String = entry[0] + if not present.has(Identity.settings_key(autoload_name)): + missing.append(entry) + return missing diff --git a/addons/godot_mcp_toolkit/core/autoload_registration.gd.uid b/addons/godot_mcp_toolkit/core/autoload_registration.gd.uid new file mode 100644 index 0000000..084c4fc --- /dev/null +++ b/addons/godot_mcp_toolkit/core/autoload_registration.gd.uid @@ -0,0 +1 @@ +uid://c2wou3vsocf0b diff --git a/addons/godot_mcp_toolkit/core/disable_cleanup_coordinator.gd b/addons/godot_mcp_toolkit/core/disable_cleanup_coordinator.gd new file mode 100644 index 0000000..c22c20d --- /dev/null +++ b/addons/godot_mcp_toolkit/core/disable_cleanup_coordinator.gd @@ -0,0 +1,145 @@ +@tool +extends RefCounted +## Runs the on-disable cleanup dialog sequence detached from the plugin instance. +## +## When the user disables the plugin, the editor frees the [EditorPlugin] the +## instant [code]_disable_plugin()[/code] returns and [code]_exit_tree()[/code] +## runs — BEFORE the user answers the cleanup prompts. Callbacks bound to the +## plugin therefore fire into a freed object and silently no-op (the prompts do +## nothing). So the cleanup sequence lives here instead: an instance is created +## and [method start]ed from [code]_disable_plugin()[/code], and it outlives the +## plugin on its own. Its dialog callbacks reference THIS coordinator (alive), +## never the freed plugin. +## +## Lifetime: the coordinator keeps a reference to itself ([member _self_ref]) +## that it clears only when the sequence terminates, so it survives the plugin +## free and the user's async dialog interaction regardless of how the dialogs +## are parented. (Belt-and-suspenders: a self-lambda bound to a RefCounted also +## holds a strong Ref to it — see GDScriptLambdaSelfCallable — but the explicit +## self-ref makes liveness obvious and owned by the coordinator.) + +# Machine-wide EditorSettings keys this plugin registers (mirror of the keys in +# plugin.gd::_register_editor_settings). These are PER-USER, MACHINE-WIDE prefs +# shared across every project that uses the toolkit, so — unlike the +# project-local ProjectSettings — they are NEVER scrubbed silently: removing them +# affects the user's other projects, which is why their removal is confirmed. +const _EDITOR_SETTING_KEYS := [ + "mcp_toolkit/personal/dock_default_visible", + "mcp_toolkit/performance/keep_editor_responsive_unfocused", + "mcp_toolkit/performance/unfocused_responsive_sleep_usec", +] + +# Self-reference that keeps this RefCounted alive across the async dialog flow, +# independent of the plugin (which is freed before the user clicks). Cleared in +# _done(), at which point the coordinator is collected. +var _self_ref: RefCounted = null + +# Absolute path to the orphaned .mcp.json, captured up front so the delete +# callback needs nothing from the plugin. +var _mcp_json_path: String = "" + + +## Begin the cleanup sequence. Prompts about an orphaned [code].mcp.json[/code] +## first (if present), then chains the machine-wide EditorSettings cleanup prompt +## off its resolution (both confirm and cancel); when there is no +## [code].mcp.json[/code] to prompt about, shows the EditorSettings prompt +## directly. The two prompts are shown SEQUENTIALLY — never two popups at once. +func start() -> void: + _self_ref = self + _mcp_json_path = ProjectSettings.globalize_path("res://") + ".mcp.json" + if FileAccess.file_exists(_mcp_json_path): + _prompt_mcp_json_orphan() + else: + _prompt_editor_settings_cleanup() + + +# Warn about the orphaned .mcp.json and offer to delete it. Either resolution +# chains to the EditorSettings prompt. +func _prompt_mcp_json_orphan() -> void: + var dialog := ConfirmationDialog.new() + dialog.exclusive = false + dialog.title = "MCP Plugin Disabled" + dialog.dialog_text = ( + "The .mcp.json configuration file is still at your project root:\n" + + _mcp_json_path + "\n\n" + + "If you're uninstalling the plugin, you may want to remove it.\n" + + "If you're just disabling temporarily, keep it.") + dialog.ok_button_text = "Delete .mcp.json" + dialog.cancel_button_text = "Keep" + dialog.confirmed.connect(func() -> void: + DirAccess.remove_absolute(_mcp_json_path) + print("[MCP] Deleted .mcp.json at %s" % _mcp_json_path) + _advance_to_editor_settings_prompt.call_deferred(dialog) + ) + dialog.canceled.connect(func() -> void: + print("[MCP] .mcp.json kept at %s" % _mcp_json_path) + _advance_to_editor_settings_prompt.call_deferred(dialog) + ) + EditorInterface.get_base_control().add_child(dialog) + dialog.popup_centered() + + +# Chain continuation between the two prompts — deferred close-then-show, never +# inline from the first prompt's signal callbacks. The prompts are non-exclusive +# (the Project Settings window, still open during a plugin toggle, holds the +# editor root's single exclusive-child slot), and a non-exclusive dialog gets no +# OS-level owner — nothing structurally keeps it above the editor window, so its +# z-order is decided purely by activation order. Showing prompt 2 inline left +# prompt 1's engine-deferred hide still pending; when that hide destroyed +# prompt 1's native window, the OS re-activated the editor OVER the just-shown +# prompt 2. Since Godot 4.6 that re-activation is processed synchronously +# (pre-4.6 it was timer-deferred and superseded before taking effect), which is +# why the buried prompt only manifests on 4.6+. Deferring this continuation +# queues it AFTER the engine's queued hide: prompt 1 is fully closed and the +# editor's re-activation has settled before prompt 2 pops — front and focused on +# every supported version (on <=4.5 the only delta is prompt 2 appearing one +# message-queue flush later). free() is safe here: running deferred means we are +# outside prompt 1's signal emission. +func _advance_to_editor_settings_prompt(finished_dialog: ConfirmationDialog) -> void: + if is_instance_valid(finished_dialog): + finished_dialog.free() + _prompt_editor_settings_cleanup() + + +# Confirm-then-erase the machine-wide EditorSettings keys this plugin registers. +# This is the terminal step — either resolution frees the dialog and calls +# _done(), which releases the coordinator. +func _prompt_editor_settings_cleanup() -> void: + var dialog := ConfirmationDialog.new() + dialog.exclusive = false + dialog.title = "MCP Plugin Disabled — Editor Preferences" + dialog.dialog_text = ( + "The MCP Toolkit also stored per-user editor preferences (dock " + + "visibility and the unfocused-responsive setting).\n\n" + + "These live in your EDITOR settings — they are machine-wide and " + + "SHARED across every project that uses the toolkit, so removing them " + + "affects your other projects too.\n\n" + + "If you're uninstalling everywhere, you may want to remove them.\n" + + "If you still use the MCP Toolkit in another project, keep them.") + dialog.ok_button_text = "Remove editor preferences" + dialog.cancel_button_text = "Keep" + dialog.confirmed.connect(func() -> void: + var editor_settings := EditorInterface.get_editor_settings() + for key in _EDITOR_SETTING_KEYS: + editor_settings.erase(key) + print("[MCP] Removed machine-wide editor preferences") + dialog.queue_free() + _done() + ) + dialog.canceled.connect(func() -> void: + print("[MCP] Kept machine-wide editor preferences") + dialog.queue_free() + _done() + ) + EditorInterface.get_base_control().add_child(dialog) + dialog.popup_centered() + # Explicit raise-and-focus: with no OS owner (non-exclusive, see the chain + # continuation above) a stray late activation of the editor window could still + # bury this prompt; a no-op when the prompt is already foreground. + dialog.grab_focus() + + +# Release the self-reference now the sequence is complete. With no other refs +# held, the coordinator is collected here. +func _done() -> void: + _self_ref = null diff --git a/addons/godot_mcp_toolkit/core/disable_cleanup_coordinator.gd.uid b/addons/godot_mcp_toolkit/core/disable_cleanup_coordinator.gd.uid new file mode 100644 index 0000000..2083c60 --- /dev/null +++ b/addons/godot_mcp_toolkit/core/disable_cleanup_coordinator.gd.uid @@ -0,0 +1 @@ +uid://d1vgei5l5kd87 diff --git a/addons/godot_mcp_toolkit/core/dock_host.gd b/addons/godot_mcp_toolkit/core/dock_host.gd new file mode 100644 index 0000000..d6b9da9 --- /dev/null +++ b/addons/godot_mcp_toolkit/core/dock_host.gd @@ -0,0 +1,90 @@ +@tool +extends RefCounted +## Version adapter that hosts the toolkit's dock in the editor across the 4.2–4.7 split. +## +## Godot 4.6 reworked editor docks: the bottom-panel tab bar moved into a +## collapsible split, so the legacy [code]add_control_to_bottom_panel[/code] path +## renders an invisible panel on 4.7. The modern [code]EditorDock[/code] + +## [code]EditorPlugin.add_dock[/code] API (new in 4.6) replaces it. This adapter +## isolates that seam behind one capability gate — +## [code]ClassDB.class_exists("EditorDock")[/code], true on 4.6+ — so the composer +## and wizard stay version-agnostic: 4.6+ routes through [code]add_dock[/code], +## 4.2–4.5 through the deprecated bottom-panel calls. +## +## The [code]EditorDock[/code] wrapper is never named as a type (the class is absent +## on 4.2–4.5, so a static reference would parse-error there); it is created via +## [code]ClassDB.instantiate[/code], stored untyped, and driven by dynamic +## [code]call(...)[/code] dispatch. Editor-only: names [code]EditorPlugin[/code], so +## it is preloaded only by editor code and never reaches the runtime autoload. + +# EditorDock.DOCK_SLOT_BOTTOM as its integer ordinal. A symbolic +# EditorDock.DOCK_SLOT_BOTTOM would parse-error on 4.2–4.5 (class absent) even in a +# dead branch, so the ordinal is hard-coded — value verified identical in the 4.6 +# and 4.7 engine source (editor/docks/dock_constants.h). +const _DOCK_SLOT_BOTTOM := 8 + +# EditorDock.DOCK_LAYOUT_HORIZONTAL as its integer ordinal (same parse-safety +# reason), matching the horizontal layout the engine's own bottom-panel docks use. +const _DOCK_LAYOUT_HORIZONTAL := 2 + + +## Host [param control] as the toolkit's dock titled [param title]; returns the host +## the other two functions need. +## +## On 4.6+ wraps [param control] in an [code]EditorDock[/code] pinned to the bottom +## slot — mirroring the engine's own bottom-panel docks (non-global, transient, +## horizontal layout) — and registers it via [code]add_dock[/code]; the returned +## [code]EditorDock[/code] is passed back to [method remove] and [method reveal]. On +## 4.2–4.5 adds [param control] through the deprecated +## [code]add_control_to_bottom_panel[/code] and returns [code]null[/code]: there is +## no wrapper, and [param control] itself is the teardown/reveal handle. +static func add(plugin: EditorPlugin, control: Control, title: String) -> Object: + if _has_editor_dock(): + var dock: Object = ClassDB.instantiate("EditorDock") + dock.call("add_child", control) + dock.call("set_title", title) + # Reproduce the engine's own bottom-panel dock setup (editor_bottom_panel.cpp + # add_item): non-global + transient so the dock is neither saved to the editor + # layout nor listed in the docks menu, pinned to the bottom slot with a + # horizontal layout. The bottom slot is load-bearing — without it add_dock + # adds the dock hidden and reveal() opens a floating window, not the panel. + dock.call("set_global", false) + dock.call("set_transient", true) + dock.call("set_default_slot", _DOCK_SLOT_BOTTOM) + dock.call("set_available_layouts", _DOCK_LAYOUT_HORIZONTAL) + plugin.call("add_dock", dock) + return dock + plugin.add_control_to_bottom_panel(control, title) + return null + + +## Remove the dock created by [method add]. [param host] is that call's return value +## (the [code]EditorDock[/code] wrapper on 4.6+, [code]null[/code] on 4.2–4.5). +## +## Unparents only — neither [param control] nor the wrapper is freed (the caller +## owns teardown; see the composer's dispose()). +static func remove(plugin: EditorPlugin, control: Control, host: Object) -> void: + if _has_editor_dock(): + plugin.call("remove_dock", host) + return + plugin.remove_control_from_bottom_panel(control) + + +## Reveal the dock: select its tab and expand the bottom panel. [param host] is +## [method add]'s return value. +## +## On 4.6+ [code]EditorDock.make_visible[/code] both selects the tab and expands the +## collapsed split; the raw [code]make_bottom_panel_item_visible[/code] only sets a +## visibility flag, leaving the panel collapsed. On 4.2–4.5 the legacy call is the +## correct reveal. +static func reveal(plugin: EditorPlugin, control: Control, host: Object) -> void: + if _has_editor_dock(): + host.call("make_visible") + return + plugin.make_bottom_panel_item_visible(control) + + +# True on Godot 4.6+, where the EditorDock + add_dock API exists; false on 4.2–4.5. +# The single capability probe the three public functions branch on. +static func _has_editor_dock() -> bool: + return ClassDB.class_exists("EditorDock") diff --git a/addons/godot_mcp_toolkit/core/dock_host.gd.uid b/addons/godot_mcp_toolkit/core/dock_host.gd.uid new file mode 100644 index 0000000..a2d40db --- /dev/null +++ b/addons/godot_mcp_toolkit/core/dock_host.gd.uid @@ -0,0 +1 @@ +uid://lykbeqxxe7yo diff --git a/addons/godot_mcp_toolkit/core/editor_access.gd b/addons/godot_mcp_toolkit/core/editor_access.gd new file mode 100644 index 0000000..b9102a7 --- /dev/null +++ b/addons/godot_mcp_toolkit/core/editor_access.gd @@ -0,0 +1,52 @@ +@tool +extends RefCounted +## Access layer to editor services, anchored on the stored EditorPlugin reference. +## +## Wraps the editor singletons (undo/redo, toaster, theme) the plugin needs so +## callers reach them through one place. Editor-only by design — only ever used +## from editor code via the hub const Modules.EditorAccess. + +## Stores the EditorPlugin instance so that EditorUndoRedoManager is accessible +## on ALL Godot 4.x versions via plugin.get_undo_redo() — not just 4.4+ where +## EditorInterface.get_editor_undo_redo() was added. +static var _plugin: EditorPlugin + + +## Inject the EditorPlugin instance to enable editor-service access (pair with [method clear_plugin]). +static func set_plugin(plugin: EditorPlugin) -> void: + _plugin = plugin + + +## Clear the stored plugin reference at teardown (pairs with [method set_plugin]). +static func clear_plugin() -> void: + _plugin = null + + +## True once the plugin reference has been injected (editor context, plugin loaded). +static func has_plugin() -> bool: + return _plugin != null + + +## Get EditorUndoRedoManager via the stored plugin reference. +## Returns the singleton on ALL Godot 4.x versions in editor context. +## Returns null only in headless mode (no plugin loaded). +static func get_undo_redo(): + if _plugin != null: + return _plugin.get_undo_redo() + return null + + +## Safely get EditorToaster via dynamic dispatch. +## The editor toaster is only available on Godot 4.4+, so guard the call and +## return null on earlier versions where the method doesn't exist. +static func get_toaster(): + if EditorInterface.has_method("get_editor_toaster"): + return EditorInterface.call("get_editor_toaster") + return null + + +## Get the editor Theme. +## EditorInterface.get_editor_theme() is bound on every supported version (4.2+), +## so no guard is needed. +static func get_editor_theme() -> Theme: + return EditorInterface.get_editor_theme() diff --git a/addons/godot_mcp_toolkit/core/editor_access.gd.uid b/addons/godot_mcp_toolkit/core/editor_access.gd.uid new file mode 100644 index 0000000..f4ba779 --- /dev/null +++ b/addons/godot_mcp_toolkit/core/editor_access.gd.uid @@ -0,0 +1 @@ +uid://1iw70vorf0gi diff --git a/addons/godot_mcp_toolkit/core/export_strip.gd b/addons/godot_mcp_toolkit/core/export_strip.gd new file mode 100644 index 0000000..e4edff6 --- /dev/null +++ b/addons/godot_mcp_toolkit/core/export_strip.gd @@ -0,0 +1,178 @@ +@tool +extends EditorExportPlugin +## Auto-strips godot_mcp_toolkit addon files, GDScript extensions, and +## res://.mcp.json from exported builds. C# extensions compile into .NET +## assemblies and cannot be stripped per-class — see docs/extending.md. +## Registered by plugin.gd; prevents MCP code shipping in game PCKs. +## +## Binary-token script export modes (Godot 4.3+ default) compile addon/extension +## .gd to .gdc in the built-in EditorExportGDScript plugin BEFORE this strip runs, +## so those scripts ship as inert, orphaned .gdc. There is no safe in-addon strip +## (set_exclude_filter is unbound through 4.6 — godotengine/godot#4054); the leak +## is cosmetic (never loaded at runtime). We WARN, we do not strip. + +# The runtime-autoload identity (name/path + key/value derivation) is owned by this +# shared leaf, so the null-for-bake here and the plugin's registration path can never +# drift — one home for the pair the export must null and restore. +const AutoloadIdentity := preload("res://addons/godot_mcp_toolkit/core/autoload_identity.gd") + +const _ADDON_PREFIX := "res://addons/godot_mcp_toolkit/" +const _MCP_JSON_PATH := "res://.mcp.json" + +# Single source of truth for "what is an extension" — must match +# extension_loader.gd's _is_extension_candidate() GDScript check: an extension +# is a DIRECT subclass of MCPToolkitExtension. Multi-level inheritance is +# intentionally unsupported (and such orphan files are harmless in builds), so +# the strip is deliberately single-level too. +const _EXTENSION_BASE := "MCPToolkitExtension" + +# EditorExportPlatform.ExportMessageType.EXPORT_MESSAGE_WARNING. Hard-coded as the +# integer ordinal (stable across 4.2–4.6, verified in engine source) because the +# enum + add_message() are GDScript-bound only on 4.4+ — a static reference would +# parse-error on 4.2/4.3 even inside a dead branch. Delivery uses has_method()+call() +# for the same reason. +const _EXPORT_MESSAGE_WARNING := 2 + +# Built in _export_begin() from the global class list. Keys = res:// paths of +# GDScript extension files (direct subclasses of MCPToolkitExtension). +var _extension_strip_paths: Dictionary = {} + +# Leak detection: observe which addon/extension files actually reach our +# _export_file. In a binary-token script mode the built-in EditorExportGDScript +# plugin consumes every .gd before our strip runs, so addon/extension SCRIPTS +# never reach us while addon NON-script files (plugin.cfg, .uid, icons) still do. +# That asymmetry tells us the scripts leaked as orphaned .gdc. Reset in +# _export_begin, read in _export_end. Version-agnostic — no preset read needed. +var _saw_addon_script: bool = false +var _saw_addon_nonscript: bool = false +var _seen_ext: Dictionary = {} # extension res:// path → true (reached _export_file) + + +func _get_name() -> String: + return "MCPExportStrip" + + +func _export_begin(_features: PackedStringArray, _is_debug: bool, _path: String, _flags: int) -> void: + _extension_strip_paths = _compute_strip_paths(ProjectSettings.get_global_class_list()) + _saw_addon_script = false + _saw_addon_nonscript = false + _seen_ext = {} + # Null each required autoload for the bake so it doesn't ship in the PCK; restored + # in _export_end. Derived per-pair from the shared identity leaf. + for entry in AutoloadIdentity.REQUIRED_AUTOLOADS: + var key := AutoloadIdentity.settings_key(str(entry[0])) + if ProjectSettings.has_setting(key): + ProjectSettings.set_setting(key, null) + + +func _export_file(path: String, _type: String, _features: PackedStringArray) -> void: + if path.begins_with(_ADDON_PREFIX): + # Track for leak detection: in binary modes only NON-script addon files + # reach us (the built-in tokenizer eats the .gd first) — see _decide_warning. + if path.ends_with(".gd"): + _saw_addon_script = true + else: + _saw_addon_nonscript = true + skip() + return + if path == _MCP_JSON_PATH: + skip() + return + if _extension_strip_paths.has(path): + _seen_ext[path] = true + skip() + return + + +func _export_end() -> void: + # Unconditional restore — self-heals if a prior export crashed mid-bake. + for entry in AutoloadIdentity.REQUIRED_AUTOLOADS: + ProjectSettings.set_setting( + AutoloadIdentity.settings_key(str(entry[0])), + AutoloadIdentity.settings_value(str(entry[1]))) + # Warn (don't strip) if a binary-token script mode shipped addon/extension .gd + # as orphaned .gdc. No safe in-addon strip exists; the leak is cosmetic. + var decision := _decide_warning(_saw_addon_script, _saw_addon_nonscript, _extension_strip_paths, _seen_ext) + if decision["warn"]: + _emit_warning(str(decision["message"])) + _extension_strip_paths.clear() + _seen_ext.clear() + + +# Deliver the binary-token leak warning. get_export_platform()/add_message() are +# GDScript-bound only on 4.4+, so use has_method()+call() dynamic dispatch — a +# static reference would parse-error on 4.2/4.3 even in a dead branch. On 4.3 (and +# as a universal fallback) push_warning() surfaces it in the Output log instead of +# the export dialog. 4.2 never reaches here (no binary mode → scripts ship as text +# → they are seen and stripped, so _decide_warning returns warn=false). +func _emit_warning(message: String) -> void: + if has_method("get_export_platform"): + var platform = call("get_export_platform") + if platform != null and platform.has_method("add_message"): + platform.call("add_message", _EXPORT_MESSAGE_WARNING, "MCP Toolkit", message) + return + push_warning(message) + + +# ── Extension scan (pure; unit-tested in test/run_unit_tests.gd) ────────── +# Single-level by design — mirrors the loader's definition of an extension (a +# DIRECT subclass of MCPToolkitExtension). The engine flattens a path-based +# `extends "res://.../some_extension.gd"` to its named base, so direct +# subclasses written either way report base == MCPToolkitExtension and are +# caught. Multi-level chains (class two+ levels deep) are NOT extensions and +# ship as harmless orphans. +static func _compute_strip_paths(classes: Array) -> Dictionary: + var strip := {} # path → true + for entry in classes: + if entry.get("base", "") != _EXTENSION_BASE: + continue + var p: String = entry.get("path", "") + if not p.is_empty() and p.ends_with(".gd"): + strip[p] = true + return strip + + +# ── Leak-warning decision (pure; unit-tested in test/run_unit_tests.gd) ─────── +# Given what reached _export_file, decide whether a binary-token script mode +# leaked addon/extension scripts as orphaned .gdc, and compose the warning. +# addon leaked ⟺ addon non-script files were exported but no addon .gd was +# (a binary mode swallowed them). The non-script signal is the +# false-positive guard: if the user already excluded the +# addon, NO addon file reaches us → saw_addon_nonscript=false +# → no warning (excluding the addon is exactly what we advise). +# ext leaked = extension paths computed in _export_begin that never reached +# _export_file. (A manually-excluded single extension also +# reads as "unseen" — documented caveat in docs/extending.md.) +# Returns {warn, addon_leaked, leaked_ext_count, message}. +static func _decide_warning(saw_addon_script: bool, saw_addon_nonscript: bool, extension_strip_paths: Dictionary, seen_ext: Dictionary) -> Dictionary: + var addon_leaked := saw_addon_nonscript and not saw_addon_script + var leaked_ext_paths: Array = [] + for p in extension_strip_paths: + if not seen_ext.has(p): + leaked_ext_paths.append(p) + var leaked_ext := leaked_ext_paths.size() + if not addon_leaked and leaked_ext == 0: + return {"warn": false, "addon_leaked": false, "leaked_ext_count": 0, "leaked_ext_paths": [], "message": ""} + var subject := "" + if addon_leaked: + subject = "the Godot MCP Toolkit addon's scripts" + if leaked_ext > 0: + if subject != "": + subject += " and " + subject += "%d extension script(s)" % leaked_ext + # Exclude-filter recipe naming exactly what leaked, so the user can copy the + # addon glob AND each specific extension path straight into the preset's filter. + var filter_parts: PackedStringArray = [] + if addon_leaked: + filter_parts.append("`res://addons/godot_mcp_toolkit/*`") + if leaked_ext > 0: + var quoted: PackedStringArray = [] + for p in leaked_ext_paths: + quoted.append("`%s`" % str(p)) + filter_parts.append("the extension script(s) " + ", ".join(quoted)) + var message := ( + "Script Export Mode is binary tokens — %s ship as inert, orphaned `.gdc` " % subject + + "(never loaded at runtime; no effect on your game). For a clean strip, set " + + "Script Export Mode to \"Text\", or add %s to this preset's exclude filter." % " and ".join(filter_parts) + ) + return {"warn": true, "addon_leaked": addon_leaked, "leaked_ext_count": leaked_ext, "leaked_ext_paths": leaked_ext_paths, "message": message} diff --git a/addons/godot_mcp_toolkit/core/export_strip.gd.uid b/addons/godot_mcp_toolkit/core/export_strip.gd.uid new file mode 100644 index 0000000..e937517 --- /dev/null +++ b/addons/godot_mcp_toolkit/core/export_strip.gd.uid @@ -0,0 +1 @@ +uid://by5f57kl4budc diff --git a/addons/godot_mcp_toolkit/core/modules.gd b/addons/godot_mcp_toolkit/core/modules.gd new file mode 100644 index 0000000..0a261d6 --- /dev/null +++ b/addons/godot_mcp_toolkit/core/modules.gd @@ -0,0 +1,26 @@ +@tool +extends RefCounted +## Centralized preloads for plugin-internal scripts. +## +## Script paths live here only — if a file moves, update this file and nothing else. +## MCPToolkitError has class_name and needs no preload. + +const Coerce := preload("res://addons/godot_mcp_toolkit/contract/coerce.gd") +const ExecuteHints := preload("res://addons/godot_mcp_toolkit/contract/execute_hints.gd") +const Pagination := preload("res://addons/godot_mcp_toolkit/contract/pagination.gd") +const ScreenshotResponse := preload("res://addons/godot_mcp_toolkit/contract/screenshot_response.gd") +const FileGuard := preload("res://addons/godot_mcp_toolkit/security/file_guard.gd") +const Untrusted := preload("res://addons/godot_mcp_toolkit/security/untrusted.gd") +const Scrubber := preload("res://addons/godot_mcp_toolkit/security/scrubber.gd") +const Audit := preload("res://addons/godot_mcp_toolkit/security/audit.gd") +const MCPJsonSync := preload("res://addons/godot_mcp_toolkit/ui/mcp_json_sync.gd") +const RegistryClient := preload("res://addons/godot_mcp_toolkit/registry/registry_client.gd") +const ProjectPaths := preload("res://addons/godot_mcp_toolkit/paths/project_paths.gd") +const UserPathMonitor := preload("res://addons/godot_mcp_toolkit/paths/user_path_monitor.gd") +const LogBuffer := preload("res://addons/godot_mcp_toolkit/logging/log_buffer.gd") +const CommandHelpers := preload("res://addons/godot_mcp_toolkit/commands/editor_helpers.gd") +const LogHelpers := preload("res://addons/godot_mcp_toolkit/logging/log_helpers.gd") +const NodejsCheck := preload("res://addons/godot_mcp_toolkit/versioning/nodejs_check.gd") +const VersionUtils := preload("res://addons/godot_mcp_toolkit/versioning/mcp_version_utils.gd") +const StaleInstanceHint := preload("res://addons/godot_mcp_toolkit/versioning/stale_instance_hint.gd") +const EditorAccess := preload("res://addons/godot_mcp_toolkit/core/editor_access.gd") diff --git a/addons/godot_mcp_toolkit/core/modules.gd.uid b/addons/godot_mcp_toolkit/core/modules.gd.uid new file mode 100644 index 0000000..d5d606d --- /dev/null +++ b/addons/godot_mcp_toolkit/core/modules.gd.uid @@ -0,0 +1 @@ +uid://dlvw4m2vjgu0a diff --git a/addons/godot_mcp_toolkit/core/playtest_end_detector.gd b/addons/godot_mcp_toolkit/core/playtest_end_detector.gd new file mode 100644 index 0000000..1579f0f --- /dev/null +++ b/addons/godot_mcp_toolkit/core/playtest_end_detector.gd @@ -0,0 +1,43 @@ +@tool +extends RefCounted +## Edge-detects the play→stop transition during a playtest. +## +## The plugin polls poll() each _process. On a play→stop edge this clears the +## runtime registry and proactively tells the MCP server bridge the game stopped, +## so the runtime channel is torn down immediately — no wait for the next +## callRuntime() to discover the dead connection. +## +## Editor-only: uses EditorInterface.is_playing_scene(), so it is constructed by +## the editor-only plugin.gd and never reached by the runtime autoload. +## +## Why poll every frame instead of connecting to a signal? Godot exposes no +## public, ClassDB-bound, cross-version (4.2–4.7) play-state signal an addon can +## connect to. The engine's play/stop signals live on the internal run-bar node +## (EditorNode.project_run_bar ≤4.4 / EditorRunBar 4.5+), which is not registered +## in ClassDB and is reachable only from inside the editor. is_playing_scene() is +## therefore the only public cross-version surface for the play→stop edge, and +## per-frame polling is the pragmatic norm. A coarse Timer is rejected: it would +## delay the runtime-clear + game_stopped broadcast past the stop edge; the poll +## body is O(1) (one bound bool call + a cached-flag compare). +## (Verified against Godot 4.2–4.7 engine source.) + +const RegistryClient := preload("res://addons/godot_mcp_toolkit/registry/registry_client.gd") + +var _server: Node = null +# Playtest-end detection for runtime port cleanup. +var _was_playing: bool = false + + +func _init(server: Node) -> void: + _server = server + + +func poll() -> void: + var playing := EditorInterface.is_playing_scene() + if _was_playing and not playing: + RegistryClient.clear_runtime() + # Proactive notification: tell the MCP server bridge the game stopped + # so it can tear down the runtime channel immediately — no need to wait + # for the next callRuntime() to discover the dead connection. + _server.broadcast_notification("game_stopped") + _was_playing = playing diff --git a/addons/godot_mcp_toolkit/core/playtest_end_detector.gd.uid b/addons/godot_mcp_toolkit/core/playtest_end_detector.gd.uid new file mode 100644 index 0000000..edeacc6 --- /dev/null +++ b/addons/godot_mcp_toolkit/core/playtest_end_detector.gd.uid @@ -0,0 +1 @@ +uid://bkovl7x0g5dt4 diff --git a/addons/godot_mcp_toolkit/core/plugin_composer.gd b/addons/godot_mcp_toolkit/core/plugin_composer.gd new file mode 100644 index 0000000..237d215 --- /dev/null +++ b/addons/godot_mcp_toolkit/core/plugin_composer.gd @@ -0,0 +1,300 @@ +@tool +extends RefCounted +## The plugin's composition root — constructs and wires the collaborator graph, +## then tears it down in reverse. +## +## compose() builds the whole object graph (registry, server, debug bridge, +## command registrar, extensions, export plugin, log buffer, user-path monitor, +## registry registration, playtest watcher, write flow + dialog presenter, dock) +## and returns the owning Handle. +## The orchestrator (plugin.gd) calls compose() once from _enter_tree, holds the +## Handle, drives it (poll_playtest each _process), and calls Handle.dispose() +## once from _exit_tree. This owns wiring, not lifecycle phases: the phase +## ordering, version-warn, and onboarding stay in the orchestrator. The +## registry-registration policy (initial register + jittered re-verify, and the +## runtime-preserving re-publish on token rotation) is folded in here as two +## private steps — it is construction-time wiring. +## +## Editor-only: names EditorInterface / the EditorPlugin host, so it is preloaded +## only by the editor-only plugin.gd and never reached by the runtime autoload. + +const Modules := preload("res://addons/godot_mcp_toolkit/core/modules.gd") +const RegistryClient = Modules.RegistryClient +const MCPServer := preload("res://addons/godot_mcp_toolkit/transport/mcp_server.gd") +const DockHost := preload("res://addons/godot_mcp_toolkit/core/dock_host.gd") +const MCPJsonWriteFlow := preload("res://addons/godot_mcp_toolkit/ui/mcp_json_write_flow.gd") +const ToolkitDialogPresenter := preload("res://addons/godot_mcp_toolkit/ui/toolkit_dialog_presenter.gd") +const MCPAuth := preload("res://addons/godot_mcp_toolkit/security/auth.gd") +const ExtensionLoader := preload("res://addons/godot_mcp_toolkit/extensions/extension_loader.gd") +const BuiltinCommandRegistration := preload("res://addons/godot_mcp_toolkit/transport/builtin_command_registration.gd") +const PlaytestEndDetector := preload("res://addons/godot_mcp_toolkit/core/playtest_end_detector.gd") +const DebugBridge := preload("res://addons/godot_mcp_toolkit/transport/debug_bridge.gd") +# Teardown counterpart of PlaytestCommands.register(..., debug_bridge): dispose() +# calls PlaytestCommands.clear_debug_bridge() as the I12 reverse of that register. +const PlaytestCommands := preload("res://addons/godot_mcp_toolkit/commands/playtest/playtest_commands.gd") + + +## Composes the plugin's collaborator graph and returns the owning Handle. +## on_user_path_changed is the orchestrator's own handler for the user-path +## monitor's signal — injected so the handler stays on the orchestrator without +## the composer poking a plugin private. +static func compose(plugin: EditorPlugin, on_user_path_changed: Callable) -> Handle: + var handle := Handle.new() + handle._plugin = plugin + + var registry := MCPToolkitCommandRegistry.new() + var server := MCPServer.new() + server.name = "MCPServer" + server.set_registry(registry) + server.editor_plugin = plugin + + # Debugger bridge — create early so command registrars can reference it. + var debug_bridge := DebugBridge.new() + plugin.add_debugger_plugin(debug_bridge) + + BuiltinCommandRegistration.register_all(registry, server, debug_bridge) + + # Third-party extensions — always loaded. + ExtensionLoader.load_all(registry, server) + # Live hot-reload: watch EditorFileSystem for extension additions/removals. + var extension_watcher := ExtensionLoader.start_watcher(registry, server) + + var export_plugin := preload("res://addons/godot_mcp_toolkit/core/export_strip.gd").new() + plugin.add_export_plugin(export_plugin) + + Modules.LogBuffer.setup() + + # Monitor the ProjectSettings that shift user:// paths (config/name, + # use_custom_user_dir, custom_user_dir_name). Each consumer connects + # directly and handles its own recovery. + var user_path_monitor = Modules.UserPathMonitor.new() + user_path_monitor.start() + user_path_monitor.user_path_changed.connect(on_user_path_changed) + + # Edge-detects the play→stop transition; polled each _process. + var playtest_end_detector := PlaytestEndDetector.new(server) + + plugin.add_child(server) + server.bind_user_path_monitor(user_path_monitor) + # After a user-path change the server re-writes its token and announces the new + # path; we re-publish the registry entry runtime-preservingly (ensure_registered, + # not register) so a game running across the rename keeps Mode-B discovery. + server.token_rewritten.connect(func(token_path: String): _republish_on_token_rewrite(server, token_path)) + # Register in the system-wide project registry on EVERY fresh bind, so the TS + # bridge can discover us by project path. Signal-driven rather than a one-shot + # after start(): a pinned port can be occupied at startup and bind only later + # (once the holder releases it), and the actual bound port must still be + # published then — the server's desync cross-check reads it as ground truth. + # Connected BEFORE start() so the normal immediate bind (inside start(), + # synchronous) registers exactly once, same as the old one-shot. + server.port_bound.connect(func(_port: int) -> void: _register_in_registry(server)) + server.start() + + # -- Cross-cutting UI collaborators, built BEFORE the dock ------------------- + # The shared .mcp.json confirm-then-write flow and the editor-global dialog + # presenter are injected into the dock, the tool menu, and the onboarding + # wizard alike: the dock consumes them like every other surface — it is a UI + # surface, not a service locator. + var write_flow := MCPJsonWriteFlow.new() + var dialog_presenter := ToolkitDialogPresenter.new() + handle._write_flow = write_flow + handle._dialog_presenter = dialog_presenter + + # -- Toolkit dock (bottom panel on ≤4.5, EditorDock on 4.6+; DockHost owns the seam) -- + var dock: Control = preload("res://addons/godot_mcp_toolkit/ui/dock/dock.tscn").instantiate() + dock.bind(server, Modules.Audit.get_log_path(), write_flow, dialog_presenter) + handle._dock = dock + handle._dock_host = DockHost.add(plugin, dock, "MCP Toolkit") + + handle._server = server + handle._export_plugin = export_plugin + handle._extension_watcher = extension_watcher + handle._debug_bridge = debug_bridge + handle._user_path_monitor = user_path_monitor + handle._playtest_end_detector = playtest_end_detector + return handle + + +# Register this editor in the system-wide project registry. Runs on every fresh +# bind (the server's port_bound signal — once at startup normally; again if a +# pinned port frees late after a startup conflict): the register() plus a jittered +# deferred ensure_registered() re-verify (concurrent editors may clobber our entry +# after our initial verify passes). Both resolves re-read the LSP endpoint at fire +# time so a mid-window LSP-setting change isn't reverted. The port guard keeps a +# stray call with no bound port a no-op. +static func _register_in_registry(server: Node) -> void: + var bound_port: int = server.get_bound_port() + if bound_port > 0: + var lsp := MCPServer.resolve_lsp_endpoint() + RegistryClient.register(bound_port, MCPAuth.get_published_token_path(), lsp["host"], lsp["port"]) + # Deferred re-verify: concurrent editors may clobber our entry after + # our initial verify passes. Jittered delay ensures all editors have + # finished their initial registration before we re-check. + var _jitter := randf_range(5.0, 10.0) + server.get_tree().create_timer(_jitter).timeout.connect( + func(): + var lsp_re := MCPServer.resolve_lsp_endpoint() + RegistryClient.ensure_registered(bound_port, MCPAuth.get_published_token_path(), lsp_re["host"], lsp_re["port"])) + + +# Re-publish this editor's registry entry after the server re-writes its token to +# a new user:// path. ensure_registered (not register) so an active playtest's +# runtime_port/runtime_pid are preserved — otherwise Mode-B (running-game) +# discovery dies for the rest of the session. Re-resolve the LSP endpoint at fire +# time, mirroring the startup registration, so a concurrent endpoint change isn't +# reverted to a stale value. +static func _republish_on_token_rewrite(server: Node, token_path: String) -> void: + if server == null: + return + var bound_port: int = server.get_bound_port() + if bound_port <= 0: + return + var lsp := MCPServer.resolve_lsp_endpoint() + # The signal carries the rewritten path in user:// form; publish it in the + # absolute form the out-of-engine server opens (see MCPAuth.get_published_token_path). + RegistryClient.ensure_registered( + bound_port, ProjectSettings.globalize_path(token_path), lsp["host"], lsp["port"] + ) + + +## Owns the collaborator graph compose() built and disposes it in reverse. +## +## The orchestrator reaches the collaborators it still needs post-compose via +## server() (menu, watcher wiring), dock() (menu), write_flow() + dialog_presenter() +## (menu, onboarding wizard), and dock_host() (the reveal façade); the other five +## instances stay private and are touched only by dispose(). poll_playtest() +## drives the playtest watcher each _process; dispose() tears the graph down in the +## exact reverse order of compose() (behavior-critical ordering). +class Handle: + extends RefCounted + + const Modules := preload("res://addons/godot_mcp_toolkit/core/modules.gd") + const RegistryClient = Modules.RegistryClient + const DockHost := preload("res://addons/godot_mcp_toolkit/core/dock_host.gd") + const MCPJsonWriteFlow := preload("res://addons/godot_mcp_toolkit/ui/mcp_json_write_flow.gd") + const ToolkitDialogPresenter := preload("res://addons/godot_mcp_toolkit/ui/toolkit_dialog_presenter.gd") + const PlaytestCommands := preload("res://addons/godot_mcp_toolkit/commands/playtest/playtest_commands.gd") + const PlaytestEndDetector := preload("res://addons/godot_mcp_toolkit/core/playtest_end_detector.gd") + + var _plugin: EditorPlugin = null + var _server: Node = null + var _export_plugin: EditorExportPlugin = null + var _dock: Control = null + var _dock_host: Object = null # EditorDock wrapper on 4.6+; null on ≤4.5 (control is its own handle) + var _write_flow: MCPJsonWriteFlow = null # Shared .mcp.json confirm-then-write flow + var _dialog_presenter: ToolkitDialogPresenter = null # Editor-global dialogs (info + catalog) + var _extension_watcher: RefCounted = null # Live hot-reload watcher (ExtensionLoader) + var _debug_bridge: RefCounted = null # EditorDebuggerPlugin for debug.* commands + var _user_path_monitor = null # UserPathMonitor — detects config/name changes + var _playtest_end_detector: PlaytestEndDetector = null # Edge-detects the play→stop transition + + ## The MCP server node (orchestrator passes it to the tool menu). + func server() -> Node: + return _server + + ## The toolkit dock control (orchestrator passes it to the tool menu, whose + ## audit action stays dock-owned). + func dock() -> Control: + return _dock + + ## The shared .mcp.json confirm-then-write flow (orchestrator passes it to the + ## tool menu + onboarding wizard). + func write_flow() -> MCPJsonWriteFlow: + return _write_flow + + ## The editor-global dialog presenter (orchestrator passes it to the tool menu + ## + onboarding wizard). + func dialog_presenter() -> ToolkitDialogPresenter: + return _dialog_presenter + + ## The dock host — the EditorDock wrapper on 4.6+, null on ≤4.5. The reveal + ## façade (plugin.reveal_dock) passes it back to DockHost.reveal. + func dock_host() -> Object: + return _dock_host + + ## Edge-detect the playtest end. Called by the orchestrator's _process. + func poll_playtest() -> void: + if _playtest_end_detector != null: + _playtest_end_detector.poll() + + ## Tear down the composed graph in reverse construction order. Behavior-critical + ## ordering — dock → dialog presenter + write flow → user-path monitor → + ## playtest watcher → extension watcher → debug bridge → export plugin → + ## server+registry. + func dispose() -> void: + # Dock teardown. Free the CONTROL immediately (never queue_free) so its + # GDScript preload chain releases before ObjectDB's exit-time leak check. + # On ≤4.5 the control is the whole handle. On 4.6+ it is a child of the + # EditorDock wrapper: free the control first (it self-detaches), then + # queue_free the wrapper — NOT free(). remove_dock() unregisters the wrapper + # but leaves it in the dock manager's dirty_docks set (remove_dock erases + # all_docks only), where a still-pending deferred _update_dirty_dock_tabs + # dereferences every entry unguarded — so an immediate free() is a + # use-after-free that corrupts the editor in the same frame a base-control + # popup is shown (the disable-cleanup prompts never render). The engine's own + # EditorDock teardown queue_frees the wrapper for this reason. The wrapper + # holds no GDScript, so deferring it does not reintroduce the leak-check + # hazard the control's immediate free() avoids. + if _dock != null: + DockHost.remove(_plugin, _dock, _dock_host) + if _dock_host != null: + _dock.free() + _dock_host.call("queue_free") + else: + _dock.free() + _dock = null + _dock_host = null + + # Editor-global dialogs (info + extension catalog) — the presenter frees + # them immediately, for the same leak-check reason the dock control is + # free()d above (they are base-control children nothing else frees). + if _dialog_presenter != null: + _dialog_presenter.dispose() + _dialog_presenter = null + + # Write flow — RefCounted with no owned nodes; drop the reference. + _write_flow = null + + # RefCounted subsystems — drop our references so they can be collected + # once the dock (which also holds them) is freed above. + if _user_path_monitor != null: + _user_path_monitor.stop() + _user_path_monitor = null + + # Playtest end detector (RefCounted — just null) — drop before server teardown + # since it holds a server reference. + _playtest_end_detector = null + + # Extension watcher — disconnect global signals, then drop before server + # teardown (holds registry ref). Without explicit disconnect, the + # filesystem_changed / settings_changed handlers become zombie callbacks. + if _extension_watcher != null: + var efs := EditorInterface.get_resource_filesystem() + if efs.filesystem_changed.is_connected(_extension_watcher.on_filesystem_changed): + efs.filesystem_changed.disconnect(_extension_watcher.on_filesystem_changed) + if ProjectSettings.settings_changed.is_connected(_extension_watcher.on_settings_changed): + ProjectSettings.settings_changed.disconnect(_extension_watcher.on_settings_changed) + _extension_watcher = null + + # Debugger bridge — unregister before server teardown (I12 symmetry). + PlaytestCommands.clear_debug_bridge() + if _debug_bridge != null: + _debug_bridge.cleanup() + _plugin.remove_debugger_plugin(_debug_bridge) + _debug_bridge = null + + # Export plugin (RefCounted — do NOT queue_free, just null). + if _export_plugin != null: + _plugin.remove_export_plugin(_export_plugin) + _export_plugin = null + + # Server + registry — clear command registry first to break the + # Callable → GDScript reference chains, then free() immediately. + # queue_free() alone causes "resources still in use at exit" because + # deferred deletion runs after ObjectDB's leak check. + if _server != null: + _server.stop() + _server.clear_registry() + RegistryClient.deregister() + _server.free() + _server = null diff --git a/addons/godot_mcp_toolkit/core/plugin_composer.gd.uid b/addons/godot_mcp_toolkit/core/plugin_composer.gd.uid new file mode 100644 index 0000000..8000bce --- /dev/null +++ b/addons/godot_mcp_toolkit/core/plugin_composer.gd.uid @@ -0,0 +1 @@ +uid://bnt500bhph8wh diff --git a/addons/godot_mcp_toolkit/core/settings_registration.gd b/addons/godot_mcp_toolkit/core/settings_registration.gd new file mode 100644 index 0000000..858fabb --- /dev/null +++ b/addons/godot_mcp_toolkit/core/settings_registration.gd @@ -0,0 +1,135 @@ +@tool +extends RefCounted +## ProjectSettings registration for limits, audit, and bootstrap keys. +## +## Called once at plugin startup to ensure mcp_toolkit/* keys appear +## in the Project Settings inspector with correct types and defaults, and the +## keys the dock also edits with the same ranges as the dock's spin boxes. + +const _BOOTSTRAP_KEY := "mcp_toolkit/internal/bootstrap_complete" + +const _LIMITS_NOTE_KEY := "mcp_toolkit/limits/env_override_note" +const _LIMITS_NOTE_TEXT := ( + "These values can be overridden by GODOT_MCP_SCRIPT_READ_LIMIT and " + + "GODOT_MCP_WS_BUFFER_LIMIT env vars in .mcp.json. " + + "When set, the env var values take priority on connect.") + + +static func register_all() -> void: + _register_limits() + _register_concurrency() + _register_audit() + _register_bootstrap_flag() + # One-way migration: mcp_toolkit/status was persisted by earlier versions and + # is no longer written, so erase whatever a project still carries — the value + # describes the machine the editor runs on and has no place in project.godot. + # The erase is in-memory only: register_all() deliberately never saves, so the + # key leaves project.godot at the next ProjectSettings save, from whatever source. + # Removable at 2.0.0 — only 1.0.0 and 1.0.1 ever wrote the key. + if ProjectSettings.has_setting("mcp_toolkit/status"): + ProjectSettings.set_setting("mcp_toolkit/status", null) + + +## Mirror of register_all — scrub every mcp_toolkit/* ProjectSettings key on +## uninstall (_disable_plugin only). Prefix-scans the live property list so it +## covers all current + future mcp_toolkit/* keys with no hardcoded list to go +## stale, then persists project.godot. Never call on _exit_tree (that fires +## every reload). +static func unregister_all() -> void: + # Two-pass: collect first (read-only), then null — never mutate the property + # list while iterating it. + var names := _collect_mcp_setting_names() + for name in names: + ProjectSettings.set_setting(name, null) + ProjectSettings.save() + + +## Read-only: collect every ProjectSettings key under the mcp_toolkit/ prefix. +## Side-effect-free core of unregister_all (and the only unit-testable seam) — +## scans ProjectSettings.get_property_list() and returns the matching names. +static func _collect_mcp_setting_names() -> PackedStringArray: + var out := PackedStringArray() + for entry in ProjectSettings.get_property_list(): + var name := str(entry.get("name", "")) + if name.begins_with("mcp_toolkit/"): + out.append(name) + return out + + +static func _register_limits() -> void: + # Max script content returned by script.read, in KB. + _register_basic_ranged_int("mcp_toolkit/limits/script_read_cap_kb", 256, "64,4096,64") + # Max user-file content returned per save.read window, in KB. + _register_basic_ranged_int("mcp_toolkit/limits/save_read_cap_kb", 256, "64,4096,64") + # WebSocket per-peer buffer size, in KB. + _register_basic_ranged_int("mcp_toolkit/limits/ws_buffer_kb", 1024, "256,8192,256") + _register_limits_note() + + +static func _register_concurrency() -> void: + _register_basic_int("mcp_toolkit/concurrency/scan_idle_timeout_ms", 5000, + "How long a scene save/open waits for the EditorFileSystem scan to finish before aborting, in ms. 0 = fail-fast; recommended 1000-30000. Higher lets slow-import projects finish scanning at the cost of longer save stalls. Not clamped.") + _register_basic_int("mcp_toolkit/concurrency/mutation_watchdog_grace_ms", 60000, + "Grace added to a mutation's deadline before the dispatch watchdog force-clears a wedged lock, in ms. Added to the command's declared timeout (or the 300s ceiling for undeclared commands). The watchdog is a safety net that should normally never fire. Not clamped.") + + +static func _register_audit() -> void: + _register_basic_bool("mcp_toolkit/audit/enabled", true, + "Enable MCP audit log at user://addons/godot_mcp_toolkit/project_instance_/mcp_audit.log.") + # Max audit log size in KB. 0 = unlimited. Log truncates to 50% when exceeded. + _register_basic_ranged_int("mcp_toolkit/audit/max_size_kb", 1024, "0,10240,128") + + +static func _register_bootstrap_flag() -> void: + if not ProjectSettings.has_setting(_BOOTSTRAP_KEY): + ProjectSettings.set_setting(_BOOTSTRAP_KEY, false) + ProjectSettings.set_initial_value(_BOOTSTRAP_KEY, false) + + +static func _register_limits_note() -> void: + ProjectSettings.set_setting(_LIMITS_NOTE_KEY, _LIMITS_NOTE_TEXT) + ProjectSettings.set_initial_value(_LIMITS_NOTE_KEY, _LIMITS_NOTE_TEXT) + ProjectSettings.set_as_basic(_LIMITS_NOTE_KEY, true) + ProjectSettings.add_property_info({ + "name": _LIMITS_NOTE_KEY, "type": TYPE_STRING, + "hint": PROPERTY_HINT_MULTILINE_TEXT, + "hint_string": "Read-only — env var override information.", + }) + + +static func _register_basic_bool(key: String, default_value: bool, description: String) -> void: + _register_basic(key, TYPE_BOOL, default_value, PROPERTY_HINT_NONE, description) + + +static func _register_basic_int(key: String, default_value: int, description: String) -> void: + _register_basic(key, TYPE_INT, default_value, PROPERTY_HINT_NONE, description) + + +## Registers an int whose Project Settings inspector editor is bounded by +## [param range_hint] ("min,max,step"). Keep each range equal to the dock spin box +## that edits the same key, so the two surfaces accept the same values; the unit +## suite asserts that they match. +## +## The hint bounds inspector edits only and never rewrites a stored value. A value +## already stored out of range or off step (5000, or 100 on a 64-step cap) keeps +## working as stored, while the inspector and the dock show it clamped or snapped +## until someone edits it. +static func _register_basic_ranged_int( + key: String, default_value: int, range_hint: String) -> void: + _register_basic(key, TYPE_INT, default_value, PROPERTY_HINT_RANGE, range_hint) + + +# Creates the key with its default only when it is absent, so a value the project +# already stores is never clobbered, then registers it as a basic setting with its +# inspector hint. +static func _register_basic( + key: String, variant_type: int, default_value: Variant, + hint: int, hint_string: String) -> void: + if not ProjectSettings.has_setting(key): + ProjectSettings.set_setting(key, default_value) + ProjectSettings.set_initial_value(key, default_value) + ProjectSettings.set_as_basic(key, true) + ProjectSettings.add_property_info({ + "name": key, "type": variant_type, + "hint": hint, "hint_string": hint_string, + }) diff --git a/addons/godot_mcp_toolkit/core/settings_registration.gd.uid b/addons/godot_mcp_toolkit/core/settings_registration.gd.uid new file mode 100644 index 0000000..b3e4390 --- /dev/null +++ b/addons/godot_mcp_toolkit/core/settings_registration.gd.uid @@ -0,0 +1 @@ +uid://11vmm0xfaap diff --git a/addons/godot_mcp_toolkit/core/tool_menu.gd b/addons/godot_mcp_toolkit/core/tool_menu.gd new file mode 100644 index 0000000..71dd30c --- /dev/null +++ b/addons/godot_mcp_toolkit/core/tool_menu.gd @@ -0,0 +1,149 @@ +@tool +extends RefCounted +## Owns the Tools > MCP Toolkit submenu + command-palette surface and routes its +## actions. +## +## Builds the data-driven submenu and the matching command-palette commands from +## one _ACTIONS table, then routes each chosen action to its collaborator: the +## server (token regen), the dock (audit log — a dock section), the shared write +## flow (.mcp.json), the dialog presenter (extension catalog), or the editor +## settings (open Project Settings). Constructed by the orchestrator (plugin.gd) +## with the collaborators its actions need; install() during _enter_tree, +## uninstall() during _exit_tree. +## +## Editor-only: names EditorInterface (command palette) and the EditorPlugin host, +## so it is constructed by the editor-only plugin.gd and never reached by the +## runtime autoload. + +const Modules := preload("res://addons/godot_mcp_toolkit/core/modules.gd") +const SettingsNavigator := preload("res://addons/godot_mcp_toolkit/ui/settings_navigator.gd") +const MCPJsonWriteFlow := preload("res://addons/godot_mcp_toolkit/ui/mcp_json_write_flow.gd") +const ToolkitDialogPresenter := preload("res://addons/godot_mcp_toolkit/ui/toolkit_dialog_presenter.gd") + +# Toast severity for a failed write (matches EditorToaster.Severity / the +# write flow's result scale: 0 info / 1 warning / 2 error). +const _TOAST_WARNING := 1 + +# Data-driven menu / command-palette registration. +# "label" is the command-palette name (prefixed for discoverability); +# "menu_label" is the short name shown inside the Tools > MCP Toolkit submenu. +const _ACTIONS := [ + {"label": "MCP Toolkit: Regenerate Token", "menu_label": "Regenerate Token", "key": "mcp/regenerate_token", "method": "_on_regen_token"}, + {"label": "MCP Toolkit: Show Audit Log", "menu_label": "Show Audit Log", "key": "mcp/show_audit_log", "method": "_on_show_audit"}, + {"label": "MCP Toolkit: Open Project Settings", "menu_label": "Open Project Settings", "key": "mcp/open_settings", "method": "_on_open_settings"}, + {"label": "MCP Toolkit: Write .mcp.json", "menu_label": "Write .mcp.json", "key": "mcp/write_mcp_json", "method": "_on_write_mcp_json"}, + {"label": "MCP Toolkit: Extension Catalog", "menu_label": "Extension Catalog...", "key": "mcp/extension_catalog", "method": "_on_extension_catalog"}, +] + +var _plugin: EditorPlugin = null +var _server: Node = null +# Kept for the audit-log action only — the audit viewer is a dock section, so the +# dock stays its owner; every other action routes to an injected collaborator. +var _dock: Control = null +var _write_flow: MCPJsonWriteFlow = null +var _dialog_presenter: ToolkitDialogPresenter = null +var _tool_submenu: PopupMenu = null + + +func _init( + plugin: EditorPlugin, server: Node, dock: Control, + write_flow: MCPJsonWriteFlow, dialog_presenter: ToolkitDialogPresenter) -> void: + _plugin = plugin + _server = server + _dock = dock + _write_flow = write_flow + _dialog_presenter = dialog_presenter + + +# -- Menu registration --------------------------------------------------------- + + +func install() -> void: + # Tools > MCP Toolkit submenu. + _tool_submenu = PopupMenu.new() + _tool_submenu.name = "MCPToolkitMenu" + for i in _ACTIONS.size(): + _tool_submenu.add_item(_ACTIONS[i]["menu_label"], i) + _tool_submenu.id_pressed.connect(_on_submenu_id_pressed) + _plugin.add_tool_submenu_item("MCP Toolkit", _tool_submenu) + # -- Command Palette (4.0+; guard anyway for safety) -- + if EditorInterface.has_method("get_command_palette"): + var palette = EditorInterface.call("get_command_palette") + if palette != null: + for action in _ACTIONS: + palette.add_command( + action["label"], action["key"], + Callable(self, action["method"])) + + +func uninstall() -> void: + # Command Palette. + if EditorInterface.has_method("get_command_palette"): + var palette = EditorInterface.call("get_command_palette") + if palette != null: + for action in _ACTIONS: + palette.remove_command(action["key"]) + # Submenu — immediate free(), not queue_free(): uninstall runs on the plugin's + # _exit_tree teardown path, and a deferred delete would hold the submenu's + # id_pressed connection (and this instance's preload chain) past ObjectDB's + # exit-time leak check → spurious "resources still in use at exit". The submenu + # was removed from the menu above, so nothing is iterating it. + _plugin.remove_tool_menu_item("MCP Toolkit") + if _tool_submenu != null and is_instance_valid(_tool_submenu): + _tool_submenu.free() + _tool_submenu = null + + +# -- Submenu router ------------------------------------------------------------ + + +func _on_submenu_id_pressed(id: int) -> void: + if id >= 0 and id < _ACTIONS.size(): + Callable(self, _ACTIONS[id]["method"]).call() + + +# -- Menu handlers ------------------------------------------------------------- + + +func _on_regen_token() -> void: + if _server != null: + _server.regenerate_token() + print("[MCP] Token rotated") + var toaster = Modules.EditorAccess.get_toaster() + if toaster != null: + toaster.push_toast("MCP token rotated", 0) + + +func _on_show_audit() -> void: + if _dock != null: + _dock.show_audit_dialog() + else: + var global_path := ProjectSettings.globalize_path(Modules.Audit.get_log_path()) + OS.shell_open(global_path) + + +func _on_open_settings() -> void: + SettingsNavigator.open_mcp_settings() + + +func _on_write_mcp_json() -> void: + if _write_flow != null: + _write_flow.write(false, _on_write_mcp_json_result) + + +# The menu's own feedback sink for the shared write flow — a Tools-menu write +# does not involve the dock panel, so the menu toasts (and console-logs) the +# outcome itself. +func _on_write_mcp_json_result(_ok: bool, message: String, severity: int, tooltip: String) -> void: + if severity >= _TOAST_WARNING: + push_warning("[MCP] %s" % message) + else: + print("[MCP] %s" % message) + var toaster = Modules.EditorAccess.get_toaster() + if toaster != null: + toaster.push_toast(message, severity, tooltip) + + +func _on_extension_catalog() -> void: + if _dialog_presenter != null: + _dialog_presenter.show_extension_catalog() diff --git a/addons/godot_mcp_toolkit/core/tool_menu.gd.uid b/addons/godot_mcp_toolkit/core/tool_menu.gd.uid new file mode 100644 index 0000000..b8ea67a --- /dev/null +++ b/addons/godot_mcp_toolkit/core/tool_menu.gd.uid @@ -0,0 +1 @@ +uid://dl2larn6d7jtk diff --git a/addons/godot_mcp_toolkit/core/unfocused_backup.gd b/addons/godot_mcp_toolkit/core/unfocused_backup.gd new file mode 100644 index 0000000..d766742 --- /dev/null +++ b/addons/godot_mcp_toolkit/core/unfocused_backup.gd @@ -0,0 +1,109 @@ +@tool +extends RefCounted +## Machine-wide, version-keyed, first-writer-wins backup of the TRUE original +## value of the global EditorSetting +## `interface/editor/unfocused_low_processor_mode_sleep_usec`. +## +## The toolkit lowers that key to keep the editor responsive while it is +## unfocused and an MCP client is connected, then restores it on the last +## disconnect. Because the live restore is in-memory, a crash (after a settings +## `save()` flushed the boost to disk) or a concurrent second editor could +## otherwise strand the global key at the boosted value — and, worse, re-read +## that boosted value as the "original" on the next launch, losing the true +## default forever (the same root bug for both the crash and the concurrent +## case: reading a boosted value as the original). +## +## This helper persists `{original, boosted}` to a sibling file in the +## machine-wide registry dir so the true original survives both a crash and a +## concurrent instance. The decision logic (`resolve_restore`, +## `should_capture_boost`) is pure and headless-unit-testable; file I/O takes an +## explicit `dir` so tests use a temp dir. Cross-instance atomicity +## (first-writer-wins) is provided by the CALLER holding the registry file lock +## around `capture_if_absent` / `delete_backup` (see mcp_server.gd) — this script +## does no locking itself, keeping it pure. +## +## File name: `unfocused_sleep_backup_.json` — version-keyed so two +## Godot editor versions running at once never conflate their separate +## EditorSettings. + +const _FILENAME_PREFIX := "unfocused_sleep_backup_" + + +## "." for the running engine, used to key the backup file. +## Pass a `{major, minor}` dict to override (unit tests). +static func version_key(version_info: Dictionary = {}) -> String: + var vi := version_info if not version_info.is_empty() else Engine.get_version_info() + return "%d.%d" % [int(vi.get("major", 0)), int(vi.get("minor", 0))] + + +static func backup_path(dir: String, ver: String) -> String: + return dir.path_join(_FILENAME_PREFIX + ver + ".json") + + +static func has_backup(dir: String, ver: String) -> bool: + return FileAccess.file_exists(backup_path(dir, ver)) + + +## Reads the backup file. Returns {} if missing or malformed, else a dict with +## int "original" and int "boosted". +static func read_backup(dir: String, ver: String) -> Dictionary: + var path := backup_path(dir, ver) + if not FileAccess.file_exists(path): + return {} + var f := FileAccess.open(path, FileAccess.READ) + if f == null: + return {} + var text := f.get_as_text() + f.close() + var parsed = JSON.parse_string(text) + if typeof(parsed) != TYPE_DICTIONARY: + return {} + if not parsed.has("original") or not parsed.has("boosted"): + return {} + return {"original": int(parsed["original"]), "boosted": int(parsed["boosted"])} + + +## First-writer-wins: writes `{original, boosted}` ONLY if no backup exists yet. +## Returns true if THIS call wrote the file, false if a backup already existed +## (this instance is not the owner — it must NOT overwrite the true original, and +## must NOT re-read a possibly-boosted live value as the original). The caller +## must hold the registry lock around this for cross-instance atomicity. +static func capture_if_absent(dir: String, original: int, boosted: int, ver: String) -> bool: + if has_backup(dir, ver): + return false + var f := FileAccess.open(backup_path(dir, ver), FileAccess.WRITE) + if f == null: + push_warning("[MCPUnfocused] cannot write backup %s (err %d)" % [ + backup_path(dir, ver), FileAccess.get_open_error()]) + return false + f.store_string(JSON.stringify({"original": original, "boosted": boosted})) + f.close() + return true + + +static func delete_backup(dir: String, ver: String) -> void: + var path := backup_path(dir, ver) + if FileAccess.file_exists(path): + DirAccess.remove_absolute(path) + + +## Pure conflict-aware restore decision. Given the live value `current` and the +## stored backup dict, decide whether to revert the key to the original: +## - empty/malformed backup → don't touch the key (no info to act on). +## - current == boosted → nobody changed it during the boost → restore original. +## - current != boosted → a human / another tool changed it → KEEP current. +## Returns `{"restore": bool, "value": int}`. When restore is true, `value` is the +## original to write; when false, `value` echoes `current` (no write needed). +static func resolve_restore(current: int, backup: Dictionary) -> Dictionary: + if backup.is_empty() or not backup.has("original") or not backup.has("boosted"): + return {"restore": false, "value": current} + if current == int(backup["boosted"]): + return {"restore": true, "value": int(backup["original"])} + return {"restore": false, "value": current} + + +## Pure gate for `unfocused_sleep_controller.lower()`: boost only when the user opted in AND +## this instance is not already boosting (idempotent). Folds the opt-out and the +## double-boost guard into one testable predicate. +static func should_capture_boost(enabled: bool, already_active: bool) -> bool: + return enabled and not already_active diff --git a/addons/godot_mcp_toolkit/core/unfocused_backup.gd.uid b/addons/godot_mcp_toolkit/core/unfocused_backup.gd.uid new file mode 100644 index 0000000..d841aa0 --- /dev/null +++ b/addons/godot_mcp_toolkit/core/unfocused_backup.gd.uid @@ -0,0 +1 @@ +uid://dtkqpw1kxvc0l diff --git a/addons/godot_mcp_toolkit/core/unfocused_sleep_controller.gd b/addons/godot_mcp_toolkit/core/unfocused_sleep_controller.gd new file mode 100644 index 0000000..9fc4ad2 --- /dev/null +++ b/addons/godot_mcp_toolkit/core/unfocused_sleep_controller.gd @@ -0,0 +1,200 @@ +@tool +extends RefCounted +## Lowers / restores the machine-wide unfocused-sleep EditorSetting while an MCP +## client is connected, crash-safe via the first-writer-wins backup. +## +## When the editor loses focus, Godot's unfocused_low_processor_mode_sleep_usec +## (default ~100000 µs ≈ 10 fps) throttles _process. Since the server polls the +## WebSocket in _process, that slows MCP interactions to ~2-3 Hz — and the editor +## is normally UNFOCUSED during an MCP session (the user is on the chat), so this +## is the common case, not an edge. While an authenticated client is connected AND +## the user has opted in, this lowers the sleep to keep the editor responsive +## unfocused, then restores it on the last disconnect / stop. +## +## The key is a machine-wide EditorSetting (every project on this editor version), +## and set_setting only reaches disk on a later save() — so a crash (after a flush) +## or a concurrent second editor could strand it at the boosted value and, worse, +## re-read that as the "original" on the next launch, losing the true default. We +## guard against that with a machine-wide, version-keyed, first-writer-wins backup +## of the TRUE original (in the registry dir, under the registry lock) plus a +## conflict-aware restore and a startup self-heal. Opt-in + tunable rate live in +## EditorSettings (registered in plugin.gd::_register_editor_settings). +## +## Editor-side ONLY — and TAINTED by design: it drives EditorSettings (via the +## injected accessor) and the registry lock. That is allowed because the Mode-B +## runtime autoload has NO unfocused sleep and must NEVER preload this file. #91713 +## therefore does not gate this child, but a runtime reference to it WOULD taint the +## autoload, so keep it strictly editor-only: only mcp_server.gd (the editor server) +## preloads it. The orchestrator owns the cross-subsystem TRIGGER points (first +## authed connect → lower; last disconnect → restore; start() → self-heal first); +## this child owns only the MECHANISM behind each. +## +## Seam the orchestrator injects (so this child is testable and the EditorInterface +## reach stays in the editor file, mirroring scene_lease's root-resolver): an +## EditorSettings accessor Callable. The pure backup/restore decision logic lives in +## unfocused_backup.gd (which this child calls directly, like the server did) and is +## headless-unit-tested there; this child only sequences those decisions against the +## live setting + the registry lock. + +const RegistryClient := preload("res://addons/godot_mcp_toolkit/registry/registry_client.gd") +const _UnfocusedBackup := preload("res://addons/godot_mcp_toolkit/core/unfocused_backup.gd") + +# The global EditorSetting we throttle, plus the two opt-in / rate keys (machine-wide). +const _UNFOCUSED_SLEEP_KEY := "interface/editor/unfocused_low_processor_mode_sleep_usec" +const _RESPONSIVE_ENABLED_KEY := "mcp_toolkit/performance/keep_editor_responsive_unfocused" +const _RESPONSIVE_SLEEP_KEY := "mcp_toolkit/performance/unfocused_responsive_sleep_usec" +# Default for the tunable EditorSetting unfocused_responsive_sleep_usec. +const _ACTIVE_UNFOCUSED_SLEEP_USEC := 16666 # ~= 60 fps while a client is connected + +# Injected EditorSettings accessor: func() -> EditorSettings (or null in headless). +# The editor injects EditorInterface.get_editor_settings; a unit test injects a stub. +# This is the ONLY way this child reaches the editor — it never names EditorInterface, +# so the EditorInterface reach lives in the editor orchestrator (mirrors scene_lease). +var _settings_accessor: Callable = Callable() + +# Best-effort in-memory mirror: >= 0 while THIS instance holds the boost active, -1 +# otherwise. The machine-wide backup FILE is the source of truth for restore (this +# only gates whether the disconnect/stop path runs). +var _original_unfocused_sleep_usec: int = -1 + + +## Wire the EditorSettings accessor. Call once at construction, before any boost. +func set_settings_accessor(settings_accessor: Callable) -> void: + _settings_accessor = settings_accessor + + +## True when the user has opted in (default true). Missing/unavailable settings +## fall back to the default so behaviour is unchanged from before this iter. +func is_unfocused_responsive_enabled() -> bool: + var es = _editor_settings() + if es == null or not es.has_setting(_RESPONSIVE_ENABLED_KEY): + return true + return bool(es.get_setting(_RESPONSIVE_ENABLED_KEY)) + + +## fps implied by the configured boosted value, for the dock indicator + log. +func get_unfocused_responsive_fps() -> int: + var usec := _configured_responsive_usec() + if usec <= 0: + return 0 + return int(round(1_000_000.0 / float(usec))) + + +## True while THIS instance holds the boost active (best-effort; the backup file +## is authoritative for restore). Used by the dock's 3-state indicator. +func is_unfocused_boost_active() -> bool: + return _original_unfocused_sleep_usec >= 0 + + +## Apply or restore the boost immediately when the user flips the opt-in toggle, +## rather than only on the next connect/disconnect. authed_peer_count lets the +## orchestrator pass the live count (this child does not own the transport). +func notify_unfocused_responsive_setting_changed(authed_peer_count: int) -> void: + if is_unfocused_responsive_enabled(): + if authed_peer_count > 0: + lower() + else: + restore() + + +## Lower the global key to the boosted value on the FIRST authed connect (idempotent +## via should_capture_boost). Captures the TRUE original to the machine-wide backup +## under the registry lock first, so a concurrent instance can't record an +## already-boosted value as the original. +func lower() -> void: + if not _UnfocusedBackup.should_capture_boost( + is_unfocused_responsive_enabled(), is_unfocused_boost_active()): + return + var es = _editor_settings() + if es == null: + return + var live := int(es.get_setting(_UNFOCUSED_SLEEP_KEY)) + var boosted := _configured_responsive_usec() + # Machine-wide first-writer-wins backup of the TRUE original, under the + # registry lock so a concurrent instance can't capture an already-boosted + # value as the original. + var dir := RegistryClient.registry_dir() + var ver := _UnfocusedBackup.version_key() + RegistryClient.acquire_lock() + _UnfocusedBackup.capture_if_absent(dir, live, boosted, ver) + RegistryClient.release_lock() + es.set_setting(_UNFOCUSED_SLEEP_KEY, boosted) + _original_unfocused_sleep_usec = live + print("[MCPServer] unfocused-responsive mode ON: %s %d -> %d (~%d fps while unfocused)" % [ + _UNFOCUSED_SLEEP_KEY, live, boosted, get_unfocused_responsive_fps()]) + + +## Restore the global key on the LAST authed disconnect / stop. Conflict-aware: if a +## human or another tool changed the key during the boost, keep their value and just +## clear the backup. +func restore() -> void: + if not is_unfocused_boost_active(): + return + var es = _editor_settings() + if es == null: + _original_unfocused_sleep_usec = -1 + return + var dir := RegistryClient.registry_dir() + var ver := _UnfocusedBackup.version_key() + RegistryClient.acquire_lock() + var backup := _UnfocusedBackup.read_backup(dir, ver) + var current := int(es.get_setting(_UNFOCUSED_SLEEP_KEY)) + var decision := _UnfocusedBackup.resolve_restore(current, backup) + if decision["restore"]: + es.set_setting(_UNFOCUSED_SLEEP_KEY, int(decision["value"])) + _UnfocusedBackup.delete_backup(dir, ver) + RegistryClient.release_lock() + _original_unfocused_sleep_usec = -1 + if decision["restore"]: + print("[MCPServer] unfocused-responsive mode OFF: %s restored to %d" % [ + _UNFOCUSED_SLEEP_KEY, int(decision["value"])]) + else: + print("[MCPServer] unfocused-responsive mode OFF: %s left at %d (changed during boost; backup cleared)" % [ + _UNFOCUSED_SLEEP_KEY, current]) + + +## Startup self-heal: if a previous session (crash) or a concurrent instance left a +## backup behind, revert the global key conflict-aware and clear the backup so the +## boost can never persist without a live connection. Runs regardless of the opt-in +## setting (a leftover from when it was ON must be cleaned even after the user turns +## it OFF). Safe at start(): no peer can be authed yet, so authed_peer_count is 0. +func self_heal(authed_peer_count: int) -> void: + var dir := RegistryClient.registry_dir() + var ver := _UnfocusedBackup.version_key() + if not _UnfocusedBackup.has_backup(dir, ver): + return + if authed_peer_count > 0: + return # defensive — never true at start() (transport not built yet / no authed peer) + var es = _editor_settings() + if es == null: + return + RegistryClient.acquire_lock() + var backup := _UnfocusedBackup.read_backup(dir, ver) + var current := int(es.get_setting(_UNFOCUSED_SLEEP_KEY)) + var decision := _UnfocusedBackup.resolve_restore(current, backup) + if decision["restore"]: + es.set_setting(_UNFOCUSED_SLEEP_KEY, int(decision["value"])) + _UnfocusedBackup.delete_backup(dir, ver) + RegistryClient.release_lock() + if decision["restore"]: + print("[MCPServer] unfocused-responsive self-heal: reverted leftover %s to %d (crash/concurrent leftover)" % [ + _UNFOCUSED_SLEEP_KEY, int(decision["value"])]) + else: + print("[MCPServer] unfocused-responsive self-heal: %s changed since boost (now %d); kept it, cleared stale backup" % [ + _UNFOCUSED_SLEEP_KEY, current]) + + +# Configured boosted sleep value in µs (default 16666 = 60 fps; not clamped). +func _configured_responsive_usec() -> int: + var es = _editor_settings() + if es == null or not es.has_setting(_RESPONSIVE_SLEEP_KEY): + return _ACTIVE_UNFOCUSED_SLEEP_USEC + return int(es.get_setting(_RESPONSIVE_SLEEP_KEY)) + + +# Resolve the live EditorSettings via the injected accessor (null in headless / when +# unwired). Keeps the EditorInterface reach in the editor orchestrator. +func _editor_settings(): + if not _settings_accessor.is_valid(): + return null + return _settings_accessor.call() diff --git a/addons/godot_mcp_toolkit/core/unfocused_sleep_controller.gd.uid b/addons/godot_mcp_toolkit/core/unfocused_sleep_controller.gd.uid new file mode 100644 index 0000000..56a84a0 --- /dev/null +++ b/addons/godot_mcp_toolkit/core/unfocused_sleep_controller.gd.uid @@ -0,0 +1 @@ +uid://c6w2ye6lgnx5f diff --git a/addons/godot_mcp_toolkit/docs/advanced_configuration.md b/addons/godot_mcp_toolkit/docs/advanced_configuration.md new file mode 100644 index 0000000..18f57a1 --- /dev/null +++ b/addons/godot_mcp_toolkit/docs/advanced_configuration.md @@ -0,0 +1,377 @@ +# Advanced Configuration + +These are **optional fine-tuning knobs** that most users never need to touch — +the defaults are chosen to work well out of the box. They live under +**Project → Project Settings → `mcp_toolkit/`** (enable *Advanced Settings* to +see them) and can also be set with `ProjectSettings`. + +Apart from the response limits under **Limits**, whose ranges the dock and the +Project Settings inspector both enforce, none of these values are clamped in +code: the recommended ranges below are guidance, not enforced limits. An extreme +value is allowed and is your responsibility. + +## Concurrency + +The toolkit serialises editor mutations and arbitrates scene access across +multiple MCP clients. These keys tune that machinery. + +### `mcp_toolkit/concurrency/scan_idle_timeout_ms` — default `5000` + +How long a scene save or open waits for an in-progress `EditorFileSystem` scan +to finish before giving up, in milliseconds. Opening or saving a scene *during* +a scan can read inconsistent filesystem state and crash the editor, so the +toolkit waits the scan out first and **aborts the operation with a `TIMEOUT` +error** if the scan hasn't settled in time — it never proceeds into an active +scan. + +- `0` = fail fast (don't wait at all). +- Recommended `1000`–`30000`. +- Raise it for large projects whose imports take long to scan, at the cost of + longer save/open stalls when a scan is genuinely stuck. + +### `mcp_toolkit/concurrency/mutation_watchdog_grace_ms` — default `60000` + +Extra grace, in milliseconds, added to a mutation's deadline before the dispatch +**watchdog** force-clears a wedged mutation lock. The deadline is the in-flight +command's *declared* `timeout_ms` (or the 300 s system maximum for a command +that doesn't declare one) **plus** this grace. + +The watchdog is a **safety net**: it only fires if a mutation handler aborts or +hangs and would otherwise block *all* further mutations until the editor +restarts. In normal operation it never fires. Lowering this shortens recovery +from such a (rare) wedge but risks force-clearing a legitimately slow handler; +the generous default makes a false fire effectively impossible for any +well-behaved command. + +### `mcp_toolkit/concurrency/scene_lease_ttl_ms` — default `8000` + +When multiple clients edit different scenes, a time-bounded *lease* prevents +cross-scene contamination: tab-dependent commands from a non-holding client +queue until the holder's lease expires. This is how long, in milliseconds, the +holder may go without renewing before a waiting client can steal the lease. + +- Lower = snappier hand-off between clients, but more tab switching. +- Higher = fewer tab switches, but a waiting client blocks longer. + +## Reading large data — caps & the pagination contract + +Read tools that can return a large payload are **capped per call** and **paginate** +instead of streaming a whole file in one response (responses are sent whole over a +size-limited WebSocket frame, so an uncapped read would silently drop). Two tools +page today — `save_read` (a `user://` file, in **bytes**) and `script_read` (a +project script, in **lines**) — and they share one **uniform pagination contract**, +so the loop you learn for one applies to the other. + +### The uniform pagination contract + +On a capped read you always get: + +- **`has_more`** (bool) — `true` when more data remains beyond this window, + `false` when this window reached the end. Always present on a successful read. +- **`total_`** — the full size of the source (`total_bytes` for `save_read`, + `total_lines` for `script_read`), so you know how much is left. +- **`returned`** — the size of this window (bytes for `save_read`, lines for + `script_read`). +- **`next_`** — the value to pass back to resume, present **only when + `has_more` is `true`** (`next_offset` for `save_read`, `next_start_line` for + `script_read`). +- **`hint`** — a one-line prose instruction, present **only when `has_more` is + `true`**, telling you exactly which cursor to feed back. + +**The loop is always the same:** call the tool; if `has_more` is `true`, pass the +returned `next_` back as the matching request parameter and call again; +stop when `has_more` is `false`. + +**Worked example — `save_read` (bytes).** Request `offset` (default `0`) + +`max_bytes`; resume with `next_offset`: + +``` +save_read path=user://saves/big.dat max_bytes=400 + → { returned: 400, offset: 0, next_offset: 400, + total_bytes: 1000, has_more: true, + hint: "more bytes remain — re-call save.read with offset = next_offset (400) until has_more is false" } +save_read path=user://saves/big.dat offset=400 max_bytes=400 + → { returned: 400, next_offset: 800, total_bytes: 1000, has_more: true, hint: … } +save_read path=user://saves/big.dat offset=800 max_bytes=400 + → { returned: 200, next_offset: 1000, total_bytes: 1000, has_more: false } # done — no hint +``` + +**Worked example — `script_read` (lines).** Request `start_line`/`end_line` +(1-based); resume with `next_start_line`: + +``` +script_read file_path=res://big.gd start_line=1 end_line=200 + → { start_line: 1, end_line: 200, total_lines: 520, returned: 200, has_more: true, + next_start_line: 201, + hint: "more lines remain — re-call script.read with start_line = next_start_line (201) until has_more is false" } +script_read file_path=res://big.gd start_line=201 end_line=400 + → { start_line: 201, end_line: 400, total_lines: 520, returned: 200, has_more: true, next_start_line: 401, hint: … } +script_read file_path=res://big.gd start_line=401 end_line=600 + → { start_line: 401, end_line: 520, total_lines: 520, returned: 120, has_more: false } # clamped to EOF, done — no hint +``` + +A **full** `script_read` (no `start_line`) that fits under the cap returns +`has_more: false` and `total_lines` as well, so both read shapes carry the same +fields. The request-parameter names stay unit-appropriate (`offset`/`max_bytes` +for bytes, `start_line`/`end_line` for lines) — only the response *shape* is +uniform. + +### When a cap change takes effect (no restart) + +The read caps below (`save_read_cap_kb`, `script_read_cap_kb`) are read by the +handler **on every call**, so a change made in the dock, in Project Settings, or by +an `.mcp.json` override applies to the **very next call**, with no restart and no +reconnect. `ws_buffer_kb` is the exception: the editor sizes a connection's buffer +when it accepts the connection, so a change applies from the next connection. + +**The server is not pushed the value.** The MCP server advertises a *static* +`max_bytes` schema bound as a sanity ceiling; the **toolkit enforces the real, +live cap at call time**. A request above the live cap is rejected with +`INVALID_PARAMS` whose message names the *current* cap — so if you lower the cap, +the model learns the new limit **reactively from that error**, not from the +advertised schema. (This is why a value that the schema appears to permit can +still be rejected: the live cap is authoritative.) + +## Limits + +The dock and the Project Settings inspector keep their own edits inside each +limit's range and step. A value set another way, such as an `.mcp.json` override +(given in bytes) or a hand edit of `project.godot`, is used as set (the save cap +still never drops below 64). Both surfaces then show it clamped to the range and +rounded to the nearest step. + +### `mcp_toolkit/limits/save_read_cap_kb` — default `256` + +The largest window `save_read` returns in a single call, in KB (64–4096, in steps +of 64). A `user://` file bigger than this cap can still be read in full by +paging — see **Reading large data** above for the `offset` / `next_offset` loop. +This is the only way to read a file larger than the WebSocket frame ceiling (see +`ws_buffer_kb` below), because responses are sent whole. + +Raising this above `ws_buffer_kb` is a footgun: a window that would not fit the +WebSocket buffer is rejected with `FILE_TOO_LARGE` before it is sent, rather than +silently dropped. If you raise the cap, raise `ws_buffer_kb` to match (and note +the runtime caveat below). + +### `mcp_toolkit/limits/script_read_cap_kb` — default `256` + +The largest payload `script_read` returns in a single call, in KB (64–4096, in +steps of 64). A project script bigger than this cap is read in full by paging on +**lines** — see **Reading large data** above for the `start_line` / +`next_start_line` loop. A full read that would exceed the cap is rejected with +`FILE_TOO_LARGE` and a hint to use a line range. + +If `GODOT_MCP_SCRIPT_READ_LIMIT` is set in `.mcp.json`, it overrides this value: +the MCP server sends it to the editor when it connects, and the dock then shows +it, clamped and rounded to the dock's range and step. The editor keeps it until it +restarts or the value is changed, and the next time project settings are saved, it +is written to `project.godot`. + +### `mcp_toolkit/limits/ws_buffer_kb` — default `1024` + +WebSocket per-peer buffer size, in KB (256–8192, in steps of 256). Raise it if you +send very large payloads (e.g. big `script_write` bodies) and see truncated or +dropped connections under load. + +If `GODOT_MCP_WS_BUFFER_LIMIT` is set in `.mcp.json`, it overrides this value in +the same way, and the dock then shows it, clamped and rounded to the dock's range +and step. The editor sizes a connection's buffer when it accepts the connection, +so the override applies to connections accepted after the server sends it, not to +the connection that sent it. + +> **Runtime (exported game) caveat.** This setting tunes the **editor** server +> only. The runtime server that runs inside an exported game uses a **fixed 1 MB +> per-peer buffer** and ignores `ws_buffer_kb`, so raising `ws_buffer_kb` does +> **not** raise the runtime ceiling — page large runtime reads with `save_read`'s +> `offset` instead. + +## Listen ports (editor & runtime) + +Two of the toolkit's three MCP channels bind a **dynamic** TCP port. By default +each **scans** a small band and publishes the bound port to the machine-wide +registry, so the MCP server discovers it with no configuration. You can instead +**pin** an exact port or **relocate** the scan band, per channel. These are read +from the process environment (the `.mcp.json` `env` block, or a shell `export` +before launch) — the toolkit never writes them. + +| Env var | Channel | Effect | Default | +|---|---|---|---| +| `GODOT_MCP_EDITOR_PORT` | Editor channel | **Pin** — bind this exact port or fail | — (scan) | +| `GODOT_MCP_EDITOR_PORT_MIN` / `_MAX` | Editor channel | **Relocate** the scan band (inclusive) | `6550` / `6560` | +| `GODOT_MCP_RUNTIME_PORT` | Runtime channel (the running game) | **Pin** — bind this exact port or fail | — (scan) | +| `GODOT_MCP_RUNTIME_PORT_MIN` / `_MAX` | Runtime channel | **Relocate** the scan band (inclusive) | `6570` / `6585` | + +The same `GODOT_MCP_EDITOR_PORT` / `GODOT_MCP_RUNTIME_PORT` are **also read by the +MCP server** to decide which port to **dial** — inherited by both processes, a pin +makes listen and dial agree with zero discovery. The `_MIN`/`_MAX` band vars are +**listen-side only** (the server never reads them; discovery covers the scanned +case). The third channel — the GDScript **LSP** — is Godot's to bind, so it stays +**connect-pin-only** via the server-side `GODOT_MCP_LSP_PORT` / `GODOT_MCP_LSP_HOST` +(next section). + +### Pinned vs. Scanned — the two modes are exclusive + +- **Pinned** (a `*_PORT` pin is set): the listener binds that **exact** port or + **fails loudly** — it never scans to a different port. If the port is occupied it + retries the same port briefly (to ride out a previous instance still releasing + it), then surfaces a **dock warning** (editor) or a loud game-console error + (runtime) naming the port. A pin makes the `_MIN`/`_MAX` band **irrelevant** — it + is ignored (a one-line note is logged). +- **Scanned** (no pin): the listener scans the band (default, or `_MIN`/`_MAX` if + set), binds the first free port, and publishes it for discovery. This is the + **low-friction** default — you don't have to manage env on both sides. If the + **whole band** is occupied, the editor shows the same dock warning naming the + range and keeps retrying. + +A malformed pin, an out-of-range port (valid range `1–65535`), or `MIN > MAX` is a +**clear error** on the dock + console, never a silent fall-back to the default. + +### An environment variable is not a sync channel + +The editor process (which **listens**) and the MCP server process (which **dials**) +read the environment **independently**. A pin only makes them agree if **both** +processes inherit the **same** value: + +- **The normal `.mcp.json` case sets `env` for the server only.** If you also launch + the Godot editor from a **desktop shortcut**, that shortcut does **not** inherit a + shell's transient `export` (notably on Windows), so the pin reaches the server but + not the editor — the server would then dial a port nobody is listening on. It now + **fails fast** with a precise message instead of hanging (and the editor dock shows + the mismatch), but the real fix is to make both sides inherit the pin. +- **The supported pattern (harness / parallel tests):** `export` the pin **once**, + then launch the editor **and** the MCP server/client as children of that same + environment so both inherit it: + + ```bash + export GODOT_MCP_EDITOR_PORT=6557 + godot --editor --path /path/to/project & # editor inherits the pin (listens on 6557) + # …launch the MCP client from the same shell so its server inherits it too (dials 6557) + ``` + +- **Prefer Scanned mode** if you don't want to manage env on both sides — registry + discovery keeps the two in agreement automatically. + +## macOS: launching your MCP client (Node / PATH) + +Modern GUI-launched MCP clients — Claude Desktop, VS Code, and Cursor — capture +your login shell's environment and resolve a bare `npx` to a version-manager Node +on their own, so the standard `npx -y @npgamedev/godot-mcp-server` config connects +whether you launch the client from Finder/Dock or a terminal. The toolkit writes +that standard command; there is nothing macOS-specific to configure. + +**If a client won't connect on macOS**, work through these: + +- **Launch the client from a terminal to see its error.** `open` won't help — start + the app's binary from a shell. A terminal launch inherits your `PATH` and prints + the client's real startup error, which tells you what to fix. +- **Confirm `.mcp.json` is present at your project root** and Node 22+ is installed + (`node --version`). If `.mcp.json` is missing, click **Write .mcp.json** in the + MCP Toolkit dock. +- **Move your version-manager init into `~/.zprofile`.** If your Node lives behind a + version manager (nvm/fnm/volta) whose init is only in `~/.zshrc`, a login-shell + launch won't see it — add it to `~/.zprofile` too so Finder/Dock launches resolve + Node as well. +- **Zero-config alternative:** install Node from the **official nodejs.org + installer**. It lands on the default `PATH`, so no shell setup is needed. + +**Point a Mac at a local server build** (development) by setting +`GODOT_MCP_DEV_SERVER_PATH` in the environment to your built `dist/index.js` — the +toolkit then emits a `node ` command instead of the released `npx` form. + +## Language server (LSP) + +The `lsp_*` tools connect to Godot's built-in GDScript language server. The MCP +server discovers the right endpoint per project from the registry, so a **single +editor needs no configuration**. These two env vars (set in a project's +`.mcp.json` `env` block) override discovery — needed only when running the LSP in +**more than one editor at once** (see `docs/multi-instance.md`). + +### `GODOT_MCP_LSP_PORT` — default: discovered (else `6005`) + +The GDScript LSP port this project's server connects to. Top priority — bypasses +registry discovery. Set it to the `--lsp-port` you launched that editor with. + +### `GODOT_MCP_LSP_HOST` — default: discovered (else `127.0.0.1`) + +The host for the GDScript LSP. Rarely needed — the LSP is localhost-only. Mirrors +the familiar `lsp.serverHost` client setting. + +## Editor responsiveness while unfocused + +> **Note — these two keys live in Editor Settings, not Project Settings.** +> Open **Editor → Editor Settings** and search for `mcp_toolkit/performance`. +> Everything else in this document is a *Project* setting; these two are the +> exception, because they control a **machine-global editor behaviour** and are a +> personal battery/CPU preference — so they are deliberately **not** written to +> `project.godot` (never committed to version control). + +When the editor loses focus, Godot throttles its process loop to a low-power +frame rate (the `interface/editor/unfocused_low_processor_mode_sleep_usec` +editor setting, ~10 fps by default). The toolkit polls its WebSocket inside that +loop, so an unfocused editor answers MCP commands only ~2–3 times per second. +During a normal MCP session the editor *is* unfocused (you're looking at the chat +window), so the toolkit raises the unfocused frame rate while a client is +connected, then restores it on the last disconnect. + +### `mcp_toolkit/performance/keep_editor_responsive_unfocused` — default `true` + +Opt-in switch. When **on** (default), the toolkit boosts the unfocused frame rate +while at least one MCP client is connected. When **off**, Godot's default +low-power unfocused throttle is left untouched — choose this if you are +battery/CPU-sensitive and don't mind slower command pickup while the editor sits +in the background. A matching toggle and a live **Off / On (idle) / On · active** +indicator are in the dock's *Server Status* section. + +### `mcp_toolkit/performance/unfocused_responsive_sleep_usec` — default `16666` + +The boosted unfocused process sleep, in microseconds. Lower = higher frame rate = +snappier commands but more background CPU. Not clamped. + +- `16666` ≈ **60 fps** (default) — maximum snappiness; also keeps automated + smoke/sweep runs fast. Zero behavioural change from earlier toolkit versions. +- `33333` ≈ **30 fps** (power-saver) — roughly half the background CPU. The + difference is imperceptible to an interactive user (command latency is + dominated by the agent's thinking time) and adds only ~20–30 s to a full + automated smoke run. +- The poll loop runs every 4th frame, so the effective MCP poll rate is about a + quarter of the frame rate (≈15 Hz at 60 fps, ≈7.5 Hz at 30 fps). + +> A quick CPU sanity-check on one machine showed an idle editor's background CPU +> at 60 fps vs 30 fps vs the 10 fps default differs only modestly; exact numbers +> are machine-specific, so treat the above as guidance, not a measurement. + +### Crash- and concurrency-safety + +The boosted value is a machine-global setting, and Godot only flushes editor +settings to disk on certain events (closing the settings dialog, quitting, …), so +a crash *after* such a flush — or a second editor running at the same time — +could otherwise leave the setting stranded at the boosted value. To prevent that: + +- Before boosting, the toolkit records the **true original** value once, to a + small machine-wide, Godot-version-keyed backup file (in the toolkit's registry + directory — the same place multi-instance discovery uses), under a + first-writer-wins file lock. A second editor that connects while the first is + already boosting will **not** overwrite that backup, so the true original is + never lost. +- On the last disconnect — and again as a **self-heal on the next editor + startup** — the toolkit reverts the setting **conflict-aware**: if the live + value still equals the value the toolkit wrote, it is restored to the true + original; if you (or another tool) changed it in the meantime, **your value is + kept** and the backup is simply cleared. Either way the boost can never persist + without a live connection. + +**Documented edge cases:** + +- If you manually set the key to *exactly* the boosted value while it is already + boosted, Godot emits no change event (same-value writes are no-ops), so the + toolkit cannot tell your value from its own — on restore it treats it as its own + and reverts to the original. This is the single case the conflict-aware check + cannot detect. +- With two editors connected at once, if the first disconnects it restores the + setting while the second is still connected, so the second runs at the default + unfocused rate until its next fresh connection. This is a brief responsiveness + dip, never a persistent change, and matches the toolkit's earlier behaviour. + +--- + +*These are advanced tunables. If you're not sure, leave the defaults.* diff --git a/addons/godot_mcp_toolkit/docs/companion-skills.md b/addons/godot_mcp_toolkit/docs/companion-skills.md new file mode 100644 index 0000000..1d1173d --- /dev/null +++ b/addons/godot_mcp_toolkit/docs/companion-skills.md @@ -0,0 +1,23 @@ +# Bundled agent skills + +The addon ships two **agent skills** alongside the toolkit — small, self-contained instruction packs that make an AI coding agent better at driving this MCP surface. They live in `addons/godot_mcp_toolkit/CompanionSkills/`, one folder per skill. They are optional: the toolkit works without them, but installing them improves how an agent uses it. + +**Installing a skill** — copy the skill's **whole folder** (including its `references/` subfolder) from `addons/godot_mcp_toolkit/CompanionSkills//` into your client's skills directory, e.g. `.claude/skills/`. Copy the entire folder, not just the top-level file — the `references/` material is part of the skill. + +Two skills ship: + +## `godot-mcp-toolkit` — the toolkit workflow skill + +**What it is.** The workflow skill teaches an agent how to use this toolkit well: which tool to reach for, how to batch tool calls, how to recover from errors, and how to keep token usage down when driving an MCP-connected editor. It is general-purpose guidance for any build — install it and the agent makes better tool choices from the first request. + +**How to install.** Copy `addons/godot_mcp_toolkit/CompanionSkills/godot-mcp-toolkit/` — the whole folder, including `references/` — into your client's skills directory (e.g. `.claude/skills/`). + +**Why.** It **measurably reduces the agent's tool calls, tokens, and wall-clock** on a typical build. In a controlled two-wave run — the same game built with and without the skill, at the same toolkit and server version — the skill arm used fewer tool calls, fewer output tokens, less wall-clock, and less money, while completing the same build. The measured figures, the exact scope (game, model, run count, versions), and the honest caveats are published here: . + +## `mcp-extension-creator` — the extension-authoring skill + +**What it is.** This toolkit lets a project register its own MCP tools in GDScript — project-specific helpers the agent calls like built-ins. The extension-authoring skill **eases the workflow of creating those new tools**: it walks an agent through authoring a distributable MCP extension end-to-end, and it carries an opt-in **guided-authoring mode** that turns extension creation into an interactive design conversation. Install it when you want the agent to build or extend the toolkit's tool surface for your project. + +**How to install.** Copy `addons/godot_mcp_toolkit/CompanionSkills/mcp-extension-creator/` — the whole folder, including `references/` — into your client's skills directory (e.g. `.claude/skills/`). + +**Learn more.** The extension configuration surface it guides you through — how tools are registered, timeouts, cancellation, hot-reload, and C# support — is documented in [extending.md](extending.md), which is the source of truth for writing an extension by hand. diff --git a/addons/godot_mcp_toolkit/docs/compatibility.md b/addons/godot_mcp_toolkit/docs/compatibility.md new file mode 100644 index 0000000..66d17d3 --- /dev/null +++ b/addons/godot_mcp_toolkit/docs/compatibility.md @@ -0,0 +1,503 @@ +# Godot Version Compatibility + +**Minimum supported:** Godot 4.2 +**Full functionality:** Godot 4.5+ +**Recommended:** Godot 4.5+ +**Tested up to:** Godot 4.7.0 + +Future Godot versions (4.8+) are not blocked. The plugin runs normally on +untested versions and logs a startup warning. + +## Version tiers + +| Godot version | Support level | Notes | +|---------------|---------------|-------| +| 4.0 - 4.1 | Not supported | EditorInterface is not a global singleton; would require wrapping 70+ call sites | +| **4.2** | Core | All tools work; some UI degradation (see below) | +| **4.3** | Core | TileMapLayer support added (tilemap tool auto-detects) | +| **4.4** | Full UI | Toast notifications added (`EditorInterface.get_editor_toaster()` is 4.4+) | +| **4.5+** | Full | All tools and UI features available | +| 4.8+ (future) | Expected | `has_method()` guards are forward-compatible; startup warning only | + +## Tool compatibility matrix + +All MCP tools work on Godot 4.2+ unless noted below. + +| Tool | Min version | Behavior on older Godot | +|------|-------------|------------------------| +| `scene_close` | 4.5 | Returns `UNSUPPORTED` error with version message. On 4.5+ closes active or inactive tabs; last-tab close auto-creates an empty scene | +| `script_check` | 4.2 | GDScript only (`.gd`); rejects `.cs` with `INVALID_PARAMS`. `gdscript://` URIs in error messages (see below); `class_name` false positive fixed via stripping | +| `editor_refresh` | 4.2 | Supports `file_paths` param for targeted O(1)-per-file mode; without params falls back to full `scan()`. Both modes work on all versions | +| `extensions_refresh` | 4.2 | On 4.2, **editing an existing** extension is not applied in-session (a cached read avoids an engine reimport crash — see below); the response `hint` names the extension and says to restart. Adding/removing extensions applies live. 4.3+ applies all changes live | +| `script_write` | 4.2 | `.gd` inline diagnostics (`valid`/`diagnostics`) on all versions. On Godot **< 4.4**, editing an existing `.gd` already attached to a **live** node carries a relaunch `hint` (the live instance keeps the old code until relaunch — see [Degraded behavior](#degraded-behavior-by-version)) | +| `node_call_method` | 4.2 | On **< 4.4**, an `INVALID_METHOD` whose method exists on the node's on-disk `.gd` (a stale live instance) carries a relaunch `hint`; a genuine typo does not. 4.4+ hot-reloads on a **display** editor, so the call just succeeds — but a **4.4+ headless** editor never re-instantiates the reloaded node, so the stale `hint` fires there too (headless wording: re-create the node or relaunch a display editor) | +| `lsp_*` (diagnostics, symbols, hover, completion, definition, references) | 4.2 | LSP works on 4.2+. **Multi-editor conflict detection is degraded < 4.5**: the cross-project root-mismatch check needs 4.5+, so on 4.2–4.4 give each editor a distinct `--lsp-port` + `GODOT_MCP_LSP_PORT` (see [multi-instance.md](multi-instance.md)) | +| `editor_get_console` | 4.2 | Captures runtime output (errors/warnings/prints) on all versions. **Editor parse errors** (from `editor_refresh` recompiling a script) surface only on **4.5+** (the Logger API hooks editor diagnostics); on **4.2–4.4** they are NOT written to `godot.log`, so neither `source="buffer"` nor `source="file"` can return them — use `script_check` or the `script_write` inline diagnostics instead. Multi-line errors that ARE captured (`SCRIPT ERROR: …` + ` at: