# Sprint 4b Implementation Plan — Editor Power Tools

## 1. Context & Goals

GodotIQ is an MCP server for AI-assisted Godot 4.x development. Phase 1 delivered 13 filesystem-only tools. Sprint 4a added 4 bridge tools (screenshot, perf_snapshot, run/stop, editor_context) via a WebSocket bridge between the Python MCP server and a GDScript editor addon.

Sprint 4b adds 4 editor power tools that transform GodotIQ from "observer" to "competent developer". After this sprint: **21 MCP tools** total.

The 4 tools split into two categories:
- **Bridge tools** (2): `godotiq_scene_tree` and `godotiq_node_ops` — execute on the editor side via WebSocket
- **Filesystem tools** (2): `godotiq_script_ops` and `godotiq_file_ops` — execute on the Python side, no addon needed

## 2. Architecture Overview

```
AI Agent
  ↕ (stdio)
Python MCP Server (FastMCP)
  ├── Bridge tools: scene_tree, node_ops → WebSocket:6007 → godotiq_server.gd
  └── Filesystem tools: script_ops, file_ops → local filesystem
```

Bridge tools follow the established two-layer pattern from Sprint 4a:
- **Implementation layer** (`tools/bridge/[tool].py`): Pure async function, calls `get_bridge()`, returns dict
- **Registration layer** (`tools/bridge/__init__.py`): `@mcp.tool()` wrapper with exception handling, annotations

Filesystem tools are self-contained Python modules that read/write the Godot project directory.

## 3. Tool 1: `godotiq_scene_tree` (A02)

### Purpose

Read the LIVE editor scene tree with spatial, script, group, and visibility context per node. Different from Phase 1's `scene_map` which reads `.tscn` files from disk — this reads the actual editor state.

### GDScript Handler

Add `scene_tree` to `godotiq_server.gd`'s `_dispatch()` match block.

The handler `_handle_scene_tree()`:
1. Get edited scene root via `EditorInterface.get_edited_scene_root()`
2. Resolve optional `root` param to a start node (validate existence)
3. Recursively walk the tree with `_walk_tree()`, respecting `depth`, `filter_type`, and `include` params
4. Return compact JSON: `{root, scene_path, total_nodes, returned_nodes, tree}`

Node data uses short keys for token efficiency: `n` (name), `t` (type), `p` (position), `r` (rotation), `s` (scale), `script`, `groups`, `visible`, `children`/`children_count`.

Helper functions:
- `_walk_tree(node, scene_root, max_depth, current_depth, filter_type, include, out_nodes)` — recursive walker. **Important**: Always increment `current_depth` when recursing into children, regardless of whether the current node matches `filter_type`. Skipping the increment on filtered-out nodes can cause unbounded recursion.
- `_count_descendants(node)` — total node count

### Python Implementation

```python
async def scene_tree(root: str = "", depth: int = 3, filter_type: str = "", include: list[str] | None = None) -> dict:
    """Get the live scene tree from the Godot editor."""
```

Clamp depth to [1, 10]. Default include: `["transform", "script", "groups", "visibility"]`. Forward to bridge as `bridge.request("scene_tree", params={...})`.

### Protocol

Add timeout `"scene_tree": 5.0` to `TOOL_TIMEOUTS` in `protocol.py`.

### MCP Registration

```python
@mcp.tool(annotations={"readOnlyHint": True, "destructiveHint": False, "idempotentHint": True, "openWorldHint": True})
```

## 4. Tool 2: `godotiq_node_ops` (A05)

### Purpose

Batch node operations with `EditorUndoRedoManager` for Ctrl+Z support. N operations in 1 call, all grouped as one undo action. This is the core editing tool.

### UndoRedo Wiring

`plugin.gd` must pass the undo_redo manager to the server, same pattern as the debugger reference:

```
# In plugin.gd _enter_tree():
_server.undo_redo = get_undo_redo()
```

Server stores as `var undo_redo` (untyped, set by plugin).

### Supported Operations

**Note**: `move`, `rotate`, and `scale` are convenience operations that handle type-specific logic (3D/2D/Control node differences). `set_property` can also change transforms but requires the agent to know the exact property name and type. Both approaches are kept — they're not redundant.

| Op | Params | Description |
|----|--------|-------------|
| `move` | `node`, `position: [x,y,z]` | Set position (3D/2D/Control) |
| `rotate` | `node`, `rotation: [x,y,z]` | Set rotation_degrees (3D) |
| `scale` | `node`, `scale: [x,y,z]` | Set scale (3D) |
| `set_property` | `node`, `property`, `value` | Any property, sub-path support (`position:x`) |
| `add_child` | `parent`, `scene`/`type`, `name`, `position` | Instantiate scene or create typed node |
| `delete` | `node` | Remove node (prevents deleting scene root) |
| `duplicate` | `node`, `name` | Duplicate node with optional new name |
| `reparent` | `node`, `new_parent` | Move to different parent |

### GDScript Handler

Add `node_ops` to `_dispatch()`. The handler `_handle_node_ops()`:

1. Validate operations array is not empty
2. Get edited scene root
3. Create single undo action: `undo_redo.create_action("GodotIQ: N node operation(s)")`
4. Iterate operations, call `_execute_node_op()` for each
5. `commit_action()` — executes all do operations immediately
6. Return results array with per-operation status

Key helper functions:
- `_execute_node_op(op_data, scene_root, undo_redo)` — dispatch by op type, return status dict
- `_find_node_by_name_or_path(name_or_path, scene_root)` — try path first, then recursive name search
- `_find_by_name_recursive(node, target_name)` — DFS name search
- `_convert_value(value, reference_value)` — JSON → Godot type conversion (Vector3, Vector2, Color)

### Critical UndoRedo Patterns

- **add_child**: Must set `owner = scene_root` after `add_child()` for persistence
- **delete**: Store parent and index for undo; use `add_undo_reference()` to prevent GC
- **duplicate**: Use `node.duplicate()`, then recursively set `owner = scene_root` on all children of the duplicate. This is critical for save persistence — without it, children lose their owner reference.
- **reparent**: Remove from old parent, add to new parent, restore index on undo
- **Empty undo guard**: Track whether any operations successfully registered undo callbacks. If ALL operations failed, skip `commit_action()` to avoid polluting the undo history with empty actions.

### Python Implementation

```python
async def node_ops(operations: list[dict]) -> dict:
    """Execute batch node operations."""
```

Validate operations list is not empty. Enforce a **50-operation limit** per call to prevent unbounded processing. Forward to bridge as `bridge.request("node_ops", params={"operations": operations})`.

### Protocol

Add timeout `"node_ops": 10.0` to `TOOL_TIMEOUTS`.

### MCP Registration

```python
@mcp.tool(annotations={"readOnlyHint": False, "destructiveHint": True, "idempotentHint": False, "openWorldHint": True})
```

## 5. Tool 3: `godotiq_script_ops` (A06)

### Purpose

Intelligent script reading/writing/patching. Python filesystem tool — no addon connection needed. Patch mode (find-and-replace) is the safest way for an AI agent to modify scripts.

### Operations

| Mode | Description |
|------|-------------|
| `read` | Read file content, return with line count and size |
| `write` | Write entire file with optional convention validation |
| `patch` | Find-and-replace with uniqueness checking (safest mode) |
| `create` | Create new file (fails if file already exists) |

### Key Design Decisions

- **Path resolution**: Supports both `res://` paths and relative paths. Resolved against `GODOTIQ_PROJECT_ROOT` env var.
- **Path containment validation**: After resolving, verify the resolved path is within the project root. Use `Path.resolve()` and check it starts with the project root path. This prevents path traversal attacks (e.g., `res://../../etc/passwd`).
- **Protected files**: project.godot, .godot/, *.import, saves/ — cannot be modified
- **Patch uniqueness**: Each search string must appear exactly once in the file, preventing accidental multi-replacements
- **Patch line numbers**: Calculate line numbers BEFORE performing each replacement, not after. This avoids off-by-one errors when prior patches shift line numbers.
- **Patch interference**: Patches are applied sequentially — earlier patches can affect later ones. This is a documented limitation. Each patch's search string is validated against the current state of the file (after prior patches).
- **Convention validation**: Basic GDScript checks (type hints) before writes, with `dry_run` option. Rewrite any fragile ternary/operator-precedence logic as explicit conditionals.
- **No addon needed**: Purely filesystem-based

### Python Implementation

```python
async def script_ops(op: str = "read", path: str = "", content: str = "", patches: list[dict] | None = None, validate_before_write: bool = True, auto_fix: bool = False, dry_run: bool = False) -> dict:
    """Read, write, patch, or create a script file."""
```

Helpers:
- `_resolve_path(path)` — resolve res:// or relative to absolute, then validate resolved path is within project root
- `_is_protected(path)` — check against protected patterns
- `_validate_gdscript(content, path)` — basic convention checks
- `_find_line_number(text, search)` — find line of search string

### MCP Registration

```python
@mcp.tool(annotations={"readOnlyHint": False, "destructiveHint": True, "idempotentHint": False, "openWorldHint": False})
```

## 6. Tool 4: `godotiq_file_ops` (A07)

### Purpose

Complete filesystem operations within the Godot project. Includes search with context lines (grep-like), tree listing with type filtering, and protected file checks.

### Operations

| Op | Description |
|----|-------------|
| `list` | List directory entries with optional type filter and recursion |
| `read` | Read file content (text only, fails on binary) |
| `write` | Write file content (with protected file check) |
| `move` | Move/rename file (with protected file check on both source AND destination) |
| `delete` | Delete file (with protected file check) |
| `search` | Text search across files with context lines |
| `tree` | Directory tree with depth control and size info |

### Type Filters

Predefined filter groups: scenes (.tscn/.scn), scripts (.gd), images (.png/.jpg/...), audio (.wav/.ogg/.mp3), fonts (.ttf/.otf/...), models (.glb/.gltf/...), shaders (.gdshader), resources (.tres/.res).

### Excluded Directories

`.godot`, `.git`, `__pycache__`, `node_modules`, `.import` — always excluded from listings and searches.

### Python Implementation

```python
async def file_ops(op: str = "list", path: str = "", query: str = "", filter: str = "all", recursive: bool = False, context_lines: int = 2, max_depth: int = 3, show_sizes: bool = False, content: str = "", destination: str = "") -> dict:
    """Execute filesystem operations."""
```

Helpers:
- `_resolve(path)` — resolve res:// or relative, then validate resolved path is within project root (same containment check as script_ops)
- `_is_protected(path)` — protected file check (used for both source AND destination in move operations)
- `_matches_type_filter(filename, filter_name)` — check against TYPE_FILTERS dict

**Note**: The `write` operation in file_ops is for non-script files. The tool description should direct the agent to use `script_ops` for `.gd` files (which provides validation).

### MCP Registration

```python
@mcp.tool(annotations={"readOnlyHint": False, "destructiveHint": True, "idempotentHint": False, "openWorldHint": False})
```

## 7. Tool Registration Changes

### `src/godotiq/tools/bridge/__init__.py`

Add 4 new `@mcp.tool()` wrappers following the established pattern:
- Import implementation functions from submodules
- Wrap with exception handling (BridgeConnectionError/TimeoutError/BridgeError for bridge tools)
- For filesystem tools (script_ops, file_ops): wrap with generic Exception handler
- Include MCP annotations

### `src/godotiq/bridge/protocol.py`

Add to `TOOL_TIMEOUTS`:
```python
"scene_tree": 5.0,
"node_ops": 10.0,
```

### `godot-addon/addons/godotiq/godotiq_plugin.gd`

Add undo_redo wiring in `_enter_tree()`:
```
_server.undo_redo = get_undo_redo()
```

## 8. Testing Strategy

### Python Unit Tests

All 4 tools get unit tests following the established pattern:

**Bridge tools** (scene_tree, node_ops):
- Mock `get_bridge()` with `AsyncMock`
- Test default params, param validation/clamping
- Test correct bridge request method and params
- Test return value passthrough

**Filesystem tools** (script_ops, file_ops):
- Use `tmp_path` fixture for real filesystem tests
- Use `monkeypatch.setenv()` for `GODOTIQ_PROJECT_ROOT` instead of raw `os.environ` manipulation
- Test each operation mode
- Test path resolution (res://, relative)
- Test path traversal prevention (resolve `../../etc/passwd` must fail)
- Test protected file enforcement
- Test edge cases (missing files, binary files, empty patches)

### Test Files

```
tests/test_bridge/
  test_scene_tree.py
  test_node_ops.py
  test_script_ops.py
  test_file_ops.py
```

## 9. Implementation Order

The recommended implementation order respects dependencies:

1. **Protocol & plugin wiring** — Add timeouts, wire undo_redo
2. **scene_tree** (GDScript handler + Python tool) — read-only, foundational
3. **node_ops** (GDScript handler + Python tool) — depends on undo_redo wiring
4. **script_ops** (Python only) — independent, no bridge needed
5. **file_ops** (Python only) — independent, no bridge needed
6. **Tool registration** — register all 4 in `__init__.py`
7. **Tests** — unit tests for all tools
