Skalowanie ekranu

This commit is contained in:
Tomasz Boruc 2026-10-09 12:49:07 +02:00
parent 6e06de4f2b
commit 73c0b16044
283 changed files with 32362 additions and 8 deletions

6
.codex/config.toml Normal file
View File

@ -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" }

16
.mcp.json Normal file
View File

@ -0,0 +1,16 @@
{
"mcpServers": {
"godot-mcp-toolkit": {
"args": [
"/c",
"npx",
"-y",
"@npgamedev/godot-mcp-server"
],
"command": "cmd",
"env": {
"GODOT_MCP_CONFIG_VERSION": "1"
}
}
}
}

View File

@ -0,0 +1,11 @@
{
"mcpServers": {
"godot-mcp-toolkit": {
"command": "npx",
"args": ["-y", "@npgamedev/godot-mcp-server"],
"env": {
"GODOT_MCP_CONFIG_VERSION": "1"
}
}
}
}

View File

@ -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: <https://github.com/Coding-Solo/godot-mcp>
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: <https://github.com/tugcantopaloglu/godot-mcp>
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: <https://github.com/salvo10f/godotiq>
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: <https://github.com/youichi-uda/godot-mcp-pro> — `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: <https://github.com/ee0pdt/Godot-MCP>
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: <https://github.com/tomyud1/godot-mcp>
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: <https://github.com/rayxuln/hastur-operation-plugin>
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: <https://github.com/AndreaTerenz/WebSocket>
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.

View File

@ -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.

View File

@ -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 <project>`
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 |

View File

@ -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.

View File

@ -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.

View File

@ -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).

View File

@ -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/<your_extension>/`, 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/<extension_name>/` directory. A GDScript extension is one `.gd` file:
```
addons/<extension_name>/
└── <extension>.gd # class_name <AnyName> extends MCPToolkitExtension
```
The skeleton — the four moving parts are the class header, the options builder,
`registry.add`, and the handler:
```gdscript
@tool
class_name <YourClassName>
extends MCPToolkitExtension
## <One-line brief of what this extension adds.>
func register(registry: MCPToolkitCommandRegistry, server: Node) -> void:
var opts := MCPToolkitExtensionOptions.new("<What this tool does>") \
.mark_read_only() \
.with_input_schema({"type": "object", "properties": {}}) # see the crib below
registry.add("<namespace>.<action>", _handler, opts)
## <Brief of what the handler does — see "Doc comments" below.>
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/<your_extension>/
├── <extension_script>.gd # class_name <YourName> 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 `<namespace>.<action>`
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` |

View File

@ -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`.

View File

@ -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<Dictionary, Dictionary>(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.

View File

@ -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()})

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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:
<https://github.com/NPGameDev/godot-mcp-toolkit/blob/main/docs/troubleshooting.md>
## 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):
<https://github.com/NPGameDev/godot-mcp-server/blob/main/docs/tool-reference/README.md>
- Toolkit repository, issues, and full documentation:
<https://github.com/NPGameDev/godot-mcp-toolkit>
## 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.

View File

@ -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)})

View File

@ -0,0 +1 @@
uid://cdb5pscckdrgg

View File

@ -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)),
})

View File

@ -0,0 +1 @@
uid://bweooujnmts8n

View File

@ -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

View File

@ -0,0 +1 @@
uid://dn0ec05sy4mgx

View File

@ -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")

View File

@ -0,0 +1 @@
uid://dadrnwydulo7x

View File

@ -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": <effective cap>, "clamped": <bool>, "error": null} on success, or
## {"error": <MCPToolkitError dict>} 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_<section>" 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)

View File

@ -0,0 +1 @@
uid://cnknbgnobxnri

View File

@ -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)

View File

@ -0,0 +1 @@
uid://bygj4dlcvs1if

View File

@ -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})

View File

@ -0,0 +1 @@
uid://boylsra28pw2v

View File

@ -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)})

View File

@ -0,0 +1 @@
uid://vw6qifnixhin

View File

@ -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)

View File

@ -0,0 +1 @@
uid://dejh5gkgjhldm

View File

@ -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/")

View File

@ -0,0 +1 @@
uid://8xlgkjtkr67f

View File

@ -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<Resource>; 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)

View File

@ -0,0 +1 @@
uid://bfqn5wl6d3b81

View File

@ -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": <coerced>} 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": <serialized>} 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

View File

@ -0,0 +1 @@
uid://cnoe7w3aw4hdp

View File

@ -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

View File

@ -0,0 +1 @@
uid://1s4drvr2wla1

View File

@ -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": ""}

View File

@ -0,0 +1 @@
uid://b3fwykd5l8fvp

View File

@ -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),
})

View File

@ -0,0 +1 @@
uid://cevcwp6fpsfxk

View File

@ -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)),
})

View File

@ -0,0 +1 @@
uid://cn08ijvjyr8o2

View File

@ -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,
})

View File

@ -0,0 +1 @@
uid://bxui4db4m5yl3

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1 @@
uid://dno7d1xxibou6

View File

@ -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

View File

@ -0,0 +1 @@
uid://danqqvfho7hh4

View File

@ -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(),
})

View File

@ -0,0 +1 @@
uid://damn3101u2ufy

View File

@ -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()

View File

@ -0,0 +1 @@
uid://brw700knbxc3v

View File

@ -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:<res://path>, 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": <stage>}
## 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,
}

View File

@ -0,0 +1 @@
uid://dfo1erycmxq0c

View File

@ -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: <message>" (script errors)
## "SCRIPT ERROR: <message>" (engine script errors)
## " at: <function> (<file>:<line>)" (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

View File

@ -0,0 +1 @@
uid://erwvhrcseuex

View File

@ -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)

View File

@ -0,0 +1 @@
uid://4a44sonc2kt0

View File

@ -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})

View File

@ -0,0 +1 @@
uid://ym3m82oivdmb

View File

@ -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/<name> 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/<name> 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

View File

@ -0,0 +1 @@
uid://dwpdf51ej5nqn

View File

@ -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(),
})

View File

@ -0,0 +1 @@
uid://5ygfiss08l3m

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1 @@
uid://c44f8eesq7s78

View File

@ -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

View File

@ -0,0 +1 @@
uid://bgfwal5f76ll

View File

@ -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 = "<freed>"
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()

View File

@ -0,0 +1 @@
uid://b1b5cux7nn8gf

View File

@ -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

View File

@ -0,0 +1 @@
uid://bqqta3evvrwpq

View File

@ -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 []

View File

@ -0,0 +1 @@
uid://c2hc3avf0dwus

View File

@ -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})

View File

@ -0,0 +1 @@
uid://4qgc3fn0tuof

View File

@ -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

View File

@ -0,0 +1 @@
uid://dux0nfvq2y70l

View File

@ -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]

View File

@ -0,0 +1 @@
uid://dwknavgut65ye

View File

@ -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)

View File

@ -0,0 +1 @@
uid://td5lwatgaw1w

View File

@ -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}

View File

@ -0,0 +1 @@
uid://btkkisxlem3si

View File

@ -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())

View File

@ -0,0 +1 @@
uid://djljrqfdxguca

View File

@ -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)])

View File

@ -0,0 +1 @@
uid://dralmnmahnxw7

View File

@ -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

View File

@ -0,0 +1 @@
uid://4vni6tubjoir

View File

@ -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 ""

View File

@ -0,0 +1 @@
uid://o4k1q1l462yh

View File

@ -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 "<unserialisable>"
_:
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 "<empty path>"
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 "<invalid class: %s>" % res_class
if not ClassDB.is_parent_class(res_class, "Resource"):
return "<not a Resource: %s>" % 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 {}

Some files were not shown because too many files have changed in this diff Show More