Skalowanie ekranu
This commit is contained in:
parent
6e06de4f2b
commit
73c0b16044
6
.codex/config.toml
Normal file
6
.codex/config.toml
Normal 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
16
.mcp.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
11
addons/godot_mcp_toolkit/.mcp.json.template
Normal file
11
addons/godot_mcp_toolkit/.mcp.json.template
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"godot-mcp-toolkit": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@npgamedev/godot-mcp-server"],
|
||||
"env": {
|
||||
"GODOT_MCP_CONFIG_VERSION": "1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
151
addons/godot_mcp_toolkit/ATTRIBUTIONS.md
Normal file
151
addons/godot_mcp_toolkit/ATTRIBUTIONS.md
Normal 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.
|
||||
8
addons/godot_mcp_toolkit/CompanionSkills/.gdignore
Normal file
8
addons/godot_mcp_toolkit/CompanionSkills/.gdignore
Normal 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.
|
||||
@ -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 |
|
||||
@ -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.
|
||||
@ -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.
|
||||
@ -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).
|
||||
@ -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` |
|
||||
@ -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`.
|
||||
@ -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.
|
||||
@ -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()})
|
||||
@ -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.
|
||||
@ -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.
|
||||
28
addons/godot_mcp_toolkit/LICENSE
Normal file
28
addons/godot_mcp_toolkit/LICENSE
Normal 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.
|
||||
101
addons/godot_mcp_toolkit/README.md
Normal file
101
addons/godot_mcp_toolkit/README.md
Normal 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.
|
||||
323
addons/godot_mcp_toolkit/commands/3d_commands.gd
Normal file
323
addons/godot_mcp_toolkit/commands/3d_commands.gd
Normal 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)})
|
||||
1
addons/godot_mcp_toolkit/commands/3d_commands.gd.uid
Normal file
1
addons/godot_mcp_toolkit/commands/3d_commands.gd.uid
Normal file
@ -0,0 +1 @@
|
||||
uid://cdb5pscckdrgg
|
||||
681
addons/godot_mcp_toolkit/commands/animation_commands.gd
Normal file
681
addons/godot_mcp_toolkit/commands/animation_commands.gd
Normal 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)),
|
||||
})
|
||||
@ -0,0 +1 @@
|
||||
uid://bweooujnmts8n
|
||||
354
addons/godot_mcp_toolkit/commands/asset_commands.gd
Normal file
354
addons/godot_mcp_toolkit/commands/asset_commands.gd
Normal 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
|
||||
1
addons/godot_mcp_toolkit/commands/asset_commands.gd.uid
Normal file
1
addons/godot_mcp_toolkit/commands/asset_commands.gd.uid
Normal file
@ -0,0 +1 @@
|
||||
uid://dn0ec05sy4mgx
|
||||
264
addons/godot_mcp_toolkit/commands/audiobus_commands.gd
Normal file
264
addons/godot_mcp_toolkit/commands/audiobus_commands.gd
Normal 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")
|
||||
@ -0,0 +1 @@
|
||||
uid://dadrnwydulo7x
|
||||
449
addons/godot_mcp_toolkit/commands/classdb_commands.gd
Normal file
449
addons/godot_mcp_toolkit/commands/classdb_commands.gd
Normal 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)
|
||||
@ -0,0 +1 @@
|
||||
uid://cnknbgnobxnri
|
||||
184
addons/godot_mcp_toolkit/commands/debug_commands.gd
Normal file
184
addons/godot_mcp_toolkit/commands/debug_commands.gd
Normal 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)
|
||||
1
addons/godot_mcp_toolkit/commands/debug_commands.gd.uid
Normal file
1
addons/godot_mcp_toolkit/commands/debug_commands.gd.uid
Normal file
@ -0,0 +1 @@
|
||||
uid://bygj4dlcvs1if
|
||||
50
addons/godot_mcp_toolkit/commands/editor/editor_commands.gd
Normal file
50
addons/godot_mcp_toolkit/commands/editor/editor_commands.gd
Normal 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})
|
||||
@ -0,0 +1 @@
|
||||
uid://boylsra28pw2v
|
||||
69
addons/godot_mcp_toolkit/commands/editor/editor_execute.gd
Normal file
69
addons/godot_mcp_toolkit/commands/editor/editor_execute.gd
Normal 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)})
|
||||
@ -0,0 +1 @@
|
||||
uid://vw6qifnixhin
|
||||
385
addons/godot_mcp_toolkit/commands/editor/editor_log_reader.gd
Normal file
385
addons/godot_mcp_toolkit/commands/editor/editor_log_reader.gd
Normal 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)
|
||||
@ -0,0 +1 @@
|
||||
uid://dejh5gkgjhldm
|
||||
116
addons/godot_mcp_toolkit/commands/editor/editor_rescan.gd
Normal file
116
addons/godot_mcp_toolkit/commands/editor/editor_rescan.gd
Normal 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/")
|
||||
@ -0,0 +1 @@
|
||||
uid://8xlgkjtkr67f
|
||||
296
addons/godot_mcp_toolkit/commands/editor/editor_screenshot.gd
Normal file
296
addons/godot_mcp_toolkit/commands/editor/editor_screenshot.gd
Normal 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)
|
||||
@ -0,0 +1 @@
|
||||
uid://bfqn5wl6d3b81
|
||||
884
addons/godot_mcp_toolkit/commands/editor_helpers.gd
Normal file
884
addons/godot_mcp_toolkit/commands/editor_helpers.gd
Normal 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
|
||||
1
addons/godot_mcp_toolkit/commands/editor_helpers.gd.uid
Normal file
1
addons/godot_mcp_toolkit/commands/editor_helpers.gd.uid
Normal file
@ -0,0 +1 @@
|
||||
uid://cnoe7w3aw4hdp
|
||||
65
addons/godot_mcp_toolkit/commands/file_commands.gd
Normal file
65
addons/godot_mcp_toolkit/commands/file_commands.gd
Normal 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
|
||||
1
addons/godot_mcp_toolkit/commands/file_commands.gd.uid
Normal file
1
addons/godot_mcp_toolkit/commands/file_commands.gd.uid
Normal file
@ -0,0 +1 @@
|
||||
uid://1s4drvr2wla1
|
||||
231
addons/godot_mcp_toolkit/commands/folder_commands.gd
Normal file
231
addons/godot_mcp_toolkit/commands/folder_commands.gd
Normal 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": ""}
|
||||
1
addons/godot_mcp_toolkit/commands/folder_commands.gd.uid
Normal file
1
addons/godot_mcp_toolkit/commands/folder_commands.gd.uid
Normal file
@ -0,0 +1 @@
|
||||
uid://b3fwykd5l8fvp
|
||||
276
addons/godot_mcp_toolkit/commands/input_map_commands.gd
Normal file
276
addons/godot_mcp_toolkit/commands/input_map_commands.gd
Normal 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),
|
||||
})
|
||||
@ -0,0 +1 @@
|
||||
uid://cevcwp6fpsfxk
|
||||
35
addons/godot_mcp_toolkit/commands/meta_commands.gd
Normal file
35
addons/godot_mcp_toolkit/commands/meta_commands.gd
Normal 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)),
|
||||
})
|
||||
1
addons/godot_mcp_toolkit/commands/meta_commands.gd.uid
Normal file
1
addons/godot_mcp_toolkit/commands/meta_commands.gd.uid
Normal file
@ -0,0 +1 @@
|
||||
uid://cn08ijvjyr8o2
|
||||
179
addons/godot_mcp_toolkit/commands/navigation_commands.gd
Normal file
179
addons/godot_mcp_toolkit/commands/navigation_commands.gd
Normal 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,
|
||||
})
|
||||
@ -0,0 +1 @@
|
||||
uid://bxui4db4m5yl3
|
||||
1208
addons/godot_mcp_toolkit/commands/node_commands.gd
Normal file
1208
addons/godot_mcp_toolkit/commands/node_commands.gd
Normal file
File diff suppressed because it is too large
Load Diff
1
addons/godot_mcp_toolkit/commands/node_commands.gd.uid
Normal file
1
addons/godot_mcp_toolkit/commands/node_commands.gd.uid
Normal file
@ -0,0 +1 @@
|
||||
uid://dno7d1xxibou6
|
||||
738
addons/godot_mcp_toolkit/commands/particle_commands.gd
Normal file
738
addons/godot_mcp_toolkit/commands/particle_commands.gd
Normal 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
|
||||
@ -0,0 +1 @@
|
||||
uid://danqqvfho7hh4
|
||||
160
addons/godot_mcp_toolkit/commands/path_commands.gd
Normal file
160
addons/godot_mcp_toolkit/commands/path_commands.gd
Normal 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(),
|
||||
})
|
||||
1
addons/godot_mcp_toolkit/commands/path_commands.gd.uid
Normal file
1
addons/godot_mcp_toolkit/commands/path_commands.gd.uid
Normal file
@ -0,0 +1 @@
|
||||
uid://damn3101u2ufy
|
||||
@ -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()
|
||||
@ -0,0 +1 @@
|
||||
uid://brw700knbxc3v
|
||||
365
addons/godot_mcp_toolkit/commands/playtest/playtest_control.gd
Normal file
365
addons/godot_mcp_toolkit/commands/playtest/playtest_control.gd
Normal 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,
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
uid://dfo1erycmxq0c
|
||||
246
addons/godot_mcp_toolkit/commands/playtest_log_reader.gd
Normal file
246
addons/godot_mcp_toolkit/commands/playtest_log_reader.gd
Normal 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
|
||||
@ -0,0 +1 @@
|
||||
uid://erwvhrcseuex
|
||||
355
addons/godot_mcp_toolkit/commands/procedural_commands.gd
Normal file
355
addons/godot_mcp_toolkit/commands/procedural_commands.gd
Normal 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)
|
||||
@ -0,0 +1 @@
|
||||
uid://4a44sonc2kt0
|
||||
261
addons/godot_mcp_toolkit/commands/project_commands.gd
Normal file
261
addons/godot_mcp_toolkit/commands/project_commands.gd
Normal 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})
|
||||
@ -0,0 +1 @@
|
||||
uid://ym3m82oivdmb
|
||||
229
addons/godot_mcp_toolkit/commands/resource_commands.gd
Normal file
229
addons/godot_mcp_toolkit/commands/resource_commands.gd
Normal 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
|
||||
@ -0,0 +1 @@
|
||||
uid://dwpdf51ej5nqn
|
||||
176
addons/godot_mcp_toolkit/commands/save_commands.gd
Normal file
176
addons/godot_mcp_toolkit/commands/save_commands.gd
Normal 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(),
|
||||
})
|
||||
1
addons/godot_mcp_toolkit/commands/save_commands.gd.uid
Normal file
1
addons/godot_mcp_toolkit/commands/save_commands.gd.uid
Normal file
@ -0,0 +1 @@
|
||||
uid://5ygfiss08l3m
|
||||
1043
addons/godot_mcp_toolkit/commands/scene_commands.gd
Normal file
1043
addons/godot_mcp_toolkit/commands/scene_commands.gd
Normal file
File diff suppressed because it is too large
Load Diff
1
addons/godot_mcp_toolkit/commands/scene_commands.gd.uid
Normal file
1
addons/godot_mcp_toolkit/commands/scene_commands.gd.uid
Normal file
@ -0,0 +1 @@
|
||||
uid://c44f8eesq7s78
|
||||
445
addons/godot_mcp_toolkit/commands/script_commands.gd
Normal file
445
addons/godot_mcp_toolkit/commands/script_commands.gd
Normal 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
|
||||
1
addons/godot_mcp_toolkit/commands/script_commands.gd.uid
Normal file
1
addons/godot_mcp_toolkit/commands/script_commands.gd.uid
Normal file
@ -0,0 +1 @@
|
||||
uid://bgfwal5f76ll
|
||||
284
addons/godot_mcp_toolkit/commands/signal_commands.gd
Normal file
284
addons/godot_mcp_toolkit/commands/signal_commands.gd
Normal 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()
|
||||
1
addons/godot_mcp_toolkit/commands/signal_commands.gd.uid
Normal file
1
addons/godot_mcp_toolkit/commands/signal_commands.gd.uid
Normal file
@ -0,0 +1 @@
|
||||
uid://b1b5cux7nn8gf
|
||||
144
addons/godot_mcp_toolkit/commands/sound_commands.gd
Normal file
144
addons/godot_mcp_toolkit/commands/sound_commands.gd
Normal 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
|
||||
1
addons/godot_mcp_toolkit/commands/sound_commands.gd.uid
Normal file
1
addons/godot_mcp_toolkit/commands/sound_commands.gd.uid
Normal file
@ -0,0 +1 @@
|
||||
uid://bqqta3evvrwpq
|
||||
329
addons/godot_mcp_toolkit/commands/spatial_commands.gd
Normal file
329
addons/godot_mcp_toolkit/commands/spatial_commands.gd
Normal 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 []
|
||||
@ -0,0 +1 @@
|
||||
uid://c2hc3avf0dwus
|
||||
371
addons/godot_mcp_toolkit/commands/spriteframes_commands.gd
Normal file
371
addons/godot_mcp_toolkit/commands/spriteframes_commands.gd
Normal 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})
|
||||
@ -0,0 +1 @@
|
||||
uid://4qgc3fn0tuof
|
||||
289
addons/godot_mcp_toolkit/commands/texture_commands.gd
Normal file
289
addons/godot_mcp_toolkit/commands/texture_commands.gd
Normal 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
|
||||
@ -0,0 +1 @@
|
||||
uid://dux0nfvq2y70l
|
||||
208
addons/godot_mcp_toolkit/commands/theme_commands.gd
Normal file
208
addons/godot_mcp_toolkit/commands/theme_commands.gd
Normal 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]
|
||||
1
addons/godot_mcp_toolkit/commands/theme_commands.gd.uid
Normal file
1
addons/godot_mcp_toolkit/commands/theme_commands.gd.uid
Normal file
@ -0,0 +1 @@
|
||||
uid://dwknavgut65ye
|
||||
314
addons/godot_mcp_toolkit/commands/tilemap_commands.gd
Normal file
314
addons/godot_mcp_toolkit/commands/tilemap_commands.gd
Normal 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)
|
||||
@ -0,0 +1 @@
|
||||
uid://td5lwatgaw1w
|
||||
@ -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}
|
||||
@ -0,0 +1 @@
|
||||
uid://btkkisxlem3si
|
||||
@ -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())
|
||||
@ -0,0 +1 @@
|
||||
uid://djljrqfdxguca
|
||||
58
addons/godot_mcp_toolkit/commands/tileset/tileset_io.gd
Normal file
58
addons/godot_mcp_toolkit/commands/tileset/tileset_io.gd
Normal 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)])
|
||||
@ -0,0 +1 @@
|
||||
uid://dralmnmahnxw7
|
||||
274
addons/godot_mcp_toolkit/commands/tileset/tileset_structure.gd
Normal file
274
addons/godot_mcp_toolkit/commands/tileset/tileset_structure.gd
Normal 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
|
||||
@ -0,0 +1 @@
|
||||
uid://4vni6tubjoir
|
||||
399
addons/godot_mcp_toolkit/commands/tileset/tileset_tile_data.gd
Normal file
399
addons/godot_mcp_toolkit/commands/tileset/tileset_tile_data.gd
Normal 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 ""
|
||||
@ -0,0 +1 @@
|
||||
uid://o4k1q1l462yh
|
||||
402
addons/godot_mcp_toolkit/contract/coerce.gd
Normal file
402
addons/godot_mcp_toolkit/contract/coerce.gd
Normal 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
Loading…
x
Reference in New Issue
Block a user