# Sprint 4b — Editor Power Tools

## Objective

Implement 4 editor manipulation tools that make the AI agent an **excellent Godot editor**. These tools transform GodotIQ from "observer" to "competent developer" — 1 tool call = what a human does in 5 minutes of clicking.

After this sprint: **21 MCP tools** total (13 Phase 1 + 4 Sprint 4a bridge + 4 Sprint 4b editor).

**Philosophy**: GDAI has 27 tools that are 1:1 API wrappers. We do the opposite: 1 call = complete action + validation + context. The agent says "move this node" and gets back "done, validated, no conflicts" or "blocked, here's why and an alternative".

---

## REFERENCE DOCUMENTS

Read these files before starting:
- `PHASE2_PLAN.md` in repo root — full architecture, protocol, API reference
- `DESIGN__1_.md` in project knowledge — tool API specs (A02, A05, A06, A07)
- `CLAUDE.md` — project conventions

## IMPORTANT CONSTRAINTS

### DO NOT TOUCH these files/directories:
- `src/godotiq/parsers/` — Phase 1 parsers
- `src/godotiq/tools/code/` — Phase 1 code tools
- `src/godotiq/tools/spatial/` — Phase 1 spatial tools
- `src/godotiq/tools/animation/` — Phase 1 animation tools
- `src/godotiq/tools/assets/` — Phase 1 asset tools
- `src/godotiq/tools/memory/` — Phase 1 memory tools
- `src/godotiq/resolvers/` — Phase 1 resolvers
- `src/godotiq/session.py` — Phase 1 session layer
- `tests/` existing test files — do NOT modify, only ADD new ones
- `godot-addon/addons/godotiq/godotiq_runtime.gd` — Sprint 4a, don't touch (Sprint 4b handlers are editor-side only)
- `godot-addon/addons/godotiq/godotiq_debugger.gd` — Sprint 4a, no changes needed
- `godot-addon/addons/godotiq/godotiq_plugin.gd` — Sprint 4a, no changes needed

### Files you WILL modify:
- `godot-addon/addons/godotiq/godotiq_server.gd` — add `node_ops` and `scene_tree` handlers
- `src/godotiq/__main__.py` (or tool registration file) — register 4 new tools

### Python dependency note:
- `websockets>=14.0` is already installed (from Sprint 4a) — this is the latest version
- No new dependencies needed for Sprint 4b

---

## TOOL 1: `godotiq_scene_tree` (A02) — Enhanced Runtime Scene Tree

**Agent must SEE before touching.** This tool returns the live scene tree from the editor with spatial + script + signal context per node. Different from Phase 1's `scene_map` (filesystem-only): this reads the LIVE editor scene tree.

### Runs: Editor-side (WebSocket → godotiq_server.gd → EditorInterface)

### GDScript handler in `godotiq_server.gd`

Add to the `_dispatch()` match block:
```
"scene_tree":
    _handle_scene_tree(peer_id, id, params)
```

Implementation:

```gdscript
func _handle_scene_tree(peer_id: int, id: String, params: Dictionary) -> void:
    var edited_root := EditorInterface.get_edited_scene_root()
    if edited_root == null:
        _send_error(peer_id, id, "NO_SCENE", "No scene is open in the editor")
        return

    var root_path: String = params.get("root", "")
    var depth: int = params.get("depth", 3)
    var filter_type: String = params.get("filter_type", "")
    var include: Array = params.get("include", ["transform", "script", "groups", "visibility"])

    var start_node: Node = edited_root
    if not root_path.is_empty():
        start_node = edited_root.get_node_or_null(root_path)
        if start_node == null:
            _send_error(peer_id, id, "NODE_NOT_FOUND", "Node '%s' not found" % root_path)
            return

    var tree_data := _build_tree(start_node, edited_root, depth, filter_type, include)

    send_response(peer_id, id, {
        "root": start_node.name,
        "scene_path": edited_root.scene_file_path,
        "total_nodes": _count_descendants(edited_root),
        "returned_nodes": tree_data["count"],
        "tree": tree_data["nodes"],
    })


func _build_tree(node: Node, scene_root: Node, depth: int, filter_type: String, include: Array) -> Dictionary:
    var nodes: Array = []
    var count: int = 0
    _walk_tree(node, scene_root, depth, 0, filter_type, include, nodes, count)
    return {"nodes": nodes, "count": nodes.size()}


func _walk_tree(node: Node, scene_root: Node, max_depth: int, current_depth: int, filter_type: String, include: Array, out_nodes: Array, count: int) -> void:
    if current_depth > max_depth:
        return

    # Apply type filter
    if not filter_type.is_empty() and not node.is_class(filter_type):
        # Still recurse children, just don't add this node
        for child in node.get_children():
            _walk_tree(child, scene_root, max_depth, current_depth, filter_type, include, out_nodes, count)
        return

    var entry: Dictionary = {
        "n": node.name,
        "t": node.get_class(),
    }

    # Position/transform
    if "transform" in include:
        if node is Node3D:
            var n3d: Node3D = node
            entry["p"] = [snapf(n3d.position.x, 0.001), snapf(n3d.position.y, 0.001), snapf(n3d.position.z, 0.001)]
            if n3d.rotation_degrees != Vector3.ZERO:
                entry["r"] = [snapf(n3d.rotation_degrees.x, 0.01), snapf(n3d.rotation_degrees.y, 0.01), snapf(n3d.rotation_degrees.z, 0.01)]
            if n3d.scale != Vector3.ONE:
                entry["s"] = [snapf(n3d.scale.x, 0.001), snapf(n3d.scale.y, 0.001), snapf(n3d.scale.z, 0.001)]
        elif node is Node2D:
            var n2d: Node2D = node
            entry["p"] = [snapf(n2d.position.x, 0.1), snapf(n2d.position.y, 0.1)]
        elif node is Control:
            var ctrl: Control = node
            entry["p"] = [snapf(ctrl.position.x, 0.1), snapf(ctrl.position.y, 0.1)]
            entry["size"] = [snapf(ctrl.size.x, 0.1), snapf(ctrl.size.y, 0.1)]

    # Script
    if "script" in include and node.get_script():
        entry["script"] = node.get_script().resource_path

    # Groups
    if "groups" in include:
        var groups: Array = []
        for g in node.get_groups():
            var gs: String = str(g)
            if not gs.begins_with("_"):  # skip internal groups
                groups.append(gs)
        if groups.size() > 0:
            entry["groups"] = groups

    # Visibility
    if "visibility" in include:
        if node is CanvasItem:
            entry["visible"] = node.visible
        elif node is Node3D:
            entry["visible"] = (node as Node3D).visible

    # Children
    if current_depth < max_depth:
        var children: Array = []
        for child in node.get_children():
            _walk_tree(child, scene_root, max_depth, current_depth + 1, filter_type, include, children, count)
        if children.size() > 0:
            entry["children"] = children
    else:
        var child_count := node.get_child_count()
        if child_count > 0:
            entry["children_count"] = child_count

    out_nodes.append(entry)


func _count_descendants(node: Node) -> int:
    var count := 1
    for child in node.get_children():
        count += _count_descendants(child)
    return count
```

### Python MCP tool: `src/godotiq/tools/bridge/scene_tree.py`

```python
"""A02 — godotiq_scene_tree: Enhanced live scene tree from the editor.

Returns the scene tree with spatial context, scripts, groups, and visibility.
Works from the editor (no game running required).
Different from Phase 1 scene_map which reads .tscn files from disk.
"""

from __future__ import annotations
from ...bridge.session import get_bridge

TOOL_NAME = "godotiq_scene_tree"
TOOL_DESCRIPTION = (
    "Get the live scene tree from the Godot editor with spatial, script, and group "
    "context per node. Use this BEFORE editing nodes to understand the scene structure. "
    "Different from scene_map (which reads .tscn files from disk). "
    "Requires addon connection but NOT a running game."
)


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

    Args:
        root: Node path relative to scene root. Empty = entire scene.
        depth: Max tree depth to return (1-10, default 3).
        filter_type: Only include nodes of this Godot type (e.g. "Node3D", "Control").
        include: What data to include per node. Default: ["transform", "script", "groups", "visibility"].

    Returns:
        Dict with root, scene_path, total_nodes, returned_nodes, tree.
    """
    depth = max(1, min(10, depth))
    if include is None:
        include = ["transform", "script", "groups", "visibility"]

    bridge = await get_bridge()
    return await bridge.request("scene_tree", params={
        "root": root,
        "depth": depth,
        "filter_type": filter_type,
        "include": include,
    })
```

---

## TOOL 2: `godotiq_node_ops` (A05) — Batch Node Operations with Undo

**The core editing tool.** N operations in 1 call, all registered with `EditorUndoRedoManager` for Ctrl+Z support. This is CRITICAL for safety — if the agent makes a mistake, the user can undo.

### Runs: Editor-side (WebSocket → godotiq_server.gd → EditorUndoRedoManager)

### Supported operations:
- `move` — set position (Vector3 for 3D, Vector2 for 2D)
- `rotate` — set rotation_degrees
- `scale` — set scale
- `set_property` — any property + sub-path support (e.g. `position:x`, `light_energy`)
- `add_child` — instantiate a scene or create new node as child
- `delete` — remove node (non-root)
- `duplicate` — duplicate node
- `reparent` — move node to different parent

### GDScript handler in `godotiq_server.gd`

Add to the `_dispatch()` match block:
```
"node_ops":
    _handle_node_ops(peer_id, id, params)
```

Implementation:

```gdscript
func _handle_node_ops(peer_id: int, id: String, params: Dictionary) -> void:
    var operations: Array = params.get("operations", [])
    if operations.is_empty():
        _send_error(peer_id, id, "NO_OPERATIONS", "No operations provided")
        return

    var edited_root := EditorInterface.get_edited_scene_root()
    if edited_root == null:
        _send_error(peer_id, id, "NO_SCENE", "No scene is open in the editor")
        return

    var undo_redo := EditorInterface.get_editor_undo_redo()
    var results: Array = []
    var action_desc := "GodotIQ: %d node operation(s)" % operations.size()

    undo_redo.create_action(action_desc)

    for op_data in operations:
        var op_result := _execute_node_op(op_data, edited_root, undo_redo)
        results.append(op_result)

    undo_redo.commit_action()

    # Check if any ops failed
    var all_ok := true
    for r in results:
        if r.get("status") != "OK":
            all_ok = false
            break

    send_response(peer_id, id, {
        "results": results,
        "scene_modified": all_ok,
        "undo_available": true,
        "action_name": action_desc,
    })


func _execute_node_op(op_data: Dictionary, scene_root: Node, undo_redo: EditorUndoRedoManager) -> Dictionary:
    var op: String = op_data.get("op", "")
    var node_path: String = op_data.get("node", "")
    var result: Dictionary = {"op": op, "node": node_path}

    match op:
        "move":
            var node := _find_node_by_name_or_path(node_path, scene_root)
            if node == null:
                result["status"] = "ERROR"
                result["error"] = "Node '%s' not found" % node_path
                return result
            var pos: Array = op_data.get("position", [])
            if node is Node3D and pos.size() >= 3:
                var old_pos: Vector3 = (node as Node3D).position
                var new_pos := Vector3(pos[0], pos[1], pos[2])
                undo_redo.add_do_property(node, "position", new_pos)
                undo_redo.add_undo_property(node, "position", old_pos)
                result["status"] = "OK"
                result["old_position"] = [snapf(old_pos.x, 0.001), snapf(old_pos.y, 0.001), snapf(old_pos.z, 0.001)]
            elif node is Node2D and pos.size() >= 2:
                var old_pos: Vector2 = (node as Node2D).position
                var new_pos := Vector2(pos[0], pos[1])
                undo_redo.add_do_property(node, "position", new_pos)
                undo_redo.add_undo_property(node, "position", old_pos)
                result["status"] = "OK"
            elif node is Control and pos.size() >= 2:
                var old_pos: Vector2 = (node as Control).position
                var new_pos := Vector2(pos[0], pos[1])
                undo_redo.add_do_property(node, "position", new_pos)
                undo_redo.add_undo_property(node, "position", old_pos)
                result["status"] = "OK"
            else:
                result["status"] = "ERROR"
                result["error"] = "Invalid position data or unsupported node type"

        "rotate":
            var node := _find_node_by_name_or_path(node_path, scene_root)
            if node == null:
                result["status"] = "ERROR"
                result["error"] = "Node '%s' not found" % node_path
                return result
            var rot: Array = op_data.get("rotation", [])
            if node is Node3D and rot.size() >= 3:
                var old_rot: Vector3 = (node as Node3D).rotation_degrees
                var new_rot := Vector3(rot[0], rot[1], rot[2])
                undo_redo.add_do_property(node, "rotation_degrees", new_rot)
                undo_redo.add_undo_property(node, "rotation_degrees", old_rot)
                result["status"] = "OK"
            else:
                result["status"] = "ERROR"
                result["error"] = "Invalid rotation data or unsupported node type"

        "scale":
            var node := _find_node_by_name_or_path(node_path, scene_root)
            if node == null:
                result["status"] = "ERROR"
                result["error"] = "Node '%s' not found" % node_path
                return result
            var scl: Array = op_data.get("scale", [])
            if node is Node3D and scl.size() >= 3:
                var old_scl: Vector3 = (node as Node3D).scale
                var new_scl := Vector3(scl[0], scl[1], scl[2])
                undo_redo.add_do_property(node, "scale", new_scl)
                undo_redo.add_undo_property(node, "scale", old_scl)
                result["status"] = "OK"
            else:
                result["status"] = "ERROR"
                result["error"] = "Invalid scale data or unsupported node type"

        "set_property":
            var node := _find_node_by_name_or_path(node_path, scene_root)
            if node == null:
                result["status"] = "ERROR"
                result["error"] = "Node '%s' not found" % node_path
                return result
            var prop: String = op_data.get("property", "")
            var value = op_data.get("value")
            if prop.is_empty():
                result["status"] = "ERROR"
                result["error"] = "Property name is empty"
                return result
            # Handle sub-path properties like "position:x"
            var old_val = node.get_indexed(prop) if ":" in prop else node.get(prop)
            undo_redo.add_do_property(node, prop, _convert_value(value, old_val))
            undo_redo.add_undo_property(node, prop, old_val)
            result["status"] = "OK"
            result["property"] = prop

        "add_child":
            var parent_path: String = op_data.get("parent", "")
            var parent := _find_node_by_name_or_path(parent_path, scene_root) if not parent_path.is_empty() else scene_root
            if parent == null:
                result["status"] = "ERROR"
                result["error"] = "Parent node '%s' not found" % parent_path
                return result
            var scene_path: String = op_data.get("scene", "")
            var node_type: String = op_data.get("type", "Node3D")
            var node_name: String = op_data.get("name", "NewNode")
            var new_node: Node
            if not scene_path.is_empty():
                var packed := ResourceLoader.load(scene_path) as PackedScene
                if packed == null:
                    result["status"] = "ERROR"
                    result["error"] = "Cannot load scene '%s'" % scene_path
                    return result
                new_node = packed.instantiate()
            else:
                new_node = ClassDB.instantiate(node_type)
                if new_node == null:
                    result["status"] = "ERROR"
                    result["error"] = "Cannot create node of type '%s'" % node_type
                    return result
            new_node.name = node_name
            # Set position if provided
            var pos: Array = op_data.get("position", [])
            if new_node is Node3D and pos.size() >= 3:
                (new_node as Node3D).position = Vector3(pos[0], pos[1], pos[2])
            elif new_node is Node2D and pos.size() >= 2:
                (new_node as Node2D).position = Vector2(pos[0], pos[1])
            undo_redo.add_do_method(parent.add_child.bind(new_node))
            undo_redo.add_do_method(new_node.set_owner.bind(scene_root))
            undo_redo.add_do_reference(new_node)
            undo_redo.add_undo_method(parent.remove_child.bind(new_node))
            result["status"] = "OK"
            result["node"] = node_name

        "delete":
            var node := _find_node_by_name_or_path(node_path, scene_root)
            if node == null:
                result["status"] = "ERROR"
                result["error"] = "Node '%s' not found" % node_path
                return result
            if node == scene_root:
                result["status"] = "ERROR"
                result["error"] = "Cannot delete scene root node"
                return result
            var parent := node.get_parent()
            var idx := node.get_index()
            undo_redo.add_do_method(parent.remove_child.bind(node))
            undo_redo.add_undo_method(parent.add_child.bind(node))
            undo_redo.add_undo_method(parent.move_child.bind(node, idx))
            undo_redo.add_undo_method(node.set_owner.bind(scene_root))
            undo_redo.add_undo_reference(node)
            result["status"] = "OK"

        "duplicate":
            var node := _find_node_by_name_or_path(node_path, scene_root)
            if node == null:
                result["status"] = "ERROR"
                result["error"] = "Node '%s' not found" % node_path
                return result
            var dup := node.duplicate()
            var new_name: String = op_data.get("name", node.name + "_copy")
            dup.name = new_name
            var parent := node.get_parent()
            undo_redo.add_do_method(parent.add_child.bind(dup))
            undo_redo.add_do_method(dup.set_owner.bind(scene_root))
            undo_redo.add_do_reference(dup)
            undo_redo.add_undo_method(parent.remove_child.bind(dup))
            result["status"] = "OK"
            result["node"] = new_name

        "reparent":
            var node := _find_node_by_name_or_path(node_path, scene_root)
            if node == null:
                result["status"] = "ERROR"
                result["error"] = "Node '%s' not found" % node_path
                return result
            var new_parent_path: String = op_data.get("new_parent", "")
            var new_parent := _find_node_by_name_or_path(new_parent_path, scene_root)
            if new_parent == null:
                result["status"] = "ERROR"
                result["error"] = "New parent '%s' not found" % new_parent_path
                return result
            var old_parent := node.get_parent()
            var old_idx := node.get_index()
            undo_redo.add_do_method(old_parent.remove_child.bind(node))
            undo_redo.add_do_method(new_parent.add_child.bind(node))
            undo_redo.add_do_method(node.set_owner.bind(scene_root))
            undo_redo.add_undo_method(new_parent.remove_child.bind(node))
            undo_redo.add_undo_method(old_parent.add_child.bind(node))
            undo_redo.add_undo_method(old_parent.move_child.bind(node, old_idx))
            undo_redo.add_undo_method(node.set_owner.bind(scene_root))
            result["status"] = "OK"

        _:
            result["status"] = "ERROR"
            result["error"] = "Unknown operation '%s'" % op

    return result


func _find_node_by_name_or_path(name_or_path: String, scene_root: Node) -> Node:
    """Find a node by name (searches recursively) or by path relative to scene root."""
    if name_or_path.is_empty():
        return null
    # Try as path first
    var by_path := scene_root.get_node_or_null(name_or_path)
    if by_path:
        return by_path
    # Then search by name recursively
    return _find_by_name_recursive(scene_root, name_or_path)


func _find_by_name_recursive(node: Node, target_name: String) -> Node:
    if node.name == target_name:
        return node
    for child in node.get_children():
        var found := _find_by_name_recursive(child, target_name)
        if found:
            return found
    return null


func _convert_value(value, reference_value) -> Variant:
    """Convert JSON values to the correct Godot type based on the reference value's type."""
    if reference_value is Vector3 and value is Array and value.size() >= 3:
        return Vector3(value[0], value[1], value[2])
    elif reference_value is Vector2 and value is Array and value.size() >= 2:
        return Vector2(value[0], value[1])
    elif reference_value is Color and value is String:
        return Color(value)
    elif reference_value is Color and value is Array and value.size() >= 3:
        return Color(value[0], value[1], value[2], value[3] if value.size() >= 4 else 1.0)
    return value
```

### Python MCP tool: `src/godotiq/tools/bridge/node_ops.py`

```python
"""A05 — godotiq_node_ops: Batch node operations with undo support.

Execute N node operations in 1 call. All operations are registered with
EditorUndoRedoManager — the user can Ctrl+Z to undo everything.

Operations: move, rotate, scale, set_property, add_child, delete, duplicate, reparent.
"""

from __future__ import annotations
from ...bridge.session import get_bridge

TOOL_NAME = "godotiq_node_ops"
TOOL_DESCRIPTION = (
    "Execute batch node operations in the Godot editor with Ctrl+Z undo support. "
    "Operations: move, rotate, scale, set_property (any property), "
    "add_child (instantiate scene or create node), delete, duplicate, reparent. "
    "All operations in a single call are grouped as one undo action. "
    "Nodes can be referenced by name (searches recursively) or by path."
)


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

    Args:
        operations: List of operation dicts. Each has:
            - op: "move"|"rotate"|"scale"|"set_property"|"add_child"|"delete"|"duplicate"|"reparent"
            - node: Node name or path (not needed for add_child)
            - ... operation-specific params (position, rotation, scale, property, value, etc.)

    Examples:
        Move: {"op": "move", "node": "StorageShelf", "position": [1.8, 0, -0.5]}
        Scale: {"op": "scale", "node": "Printer_1", "scale": [0.4, 0.4, 0.4]}
        Set property: {"op": "set_property", "node": "Sun", "property": "light_energy", "value": 1.2}
        Add child: {"op": "add_child", "parent": "Entities", "scene": "res://scenes/printer.tscn", "name": "Printer_7", "position": [3, 0, -2]}
        Delete: {"op": "delete", "node": "OldShelf"}
        Duplicate: {"op": "duplicate", "node": "Shelf_1", "name": "Shelf_2"}
        Reparent: {"op": "reparent", "node": "Misplaced", "new_parent": "Entities"}

    Returns:
        Dict with results per operation, scene_modified, undo_available.
    """
    if not operations:
        return {"error": "No operations provided", "results": []}

    bridge = await get_bridge()
    return await bridge.request("node_ops", params={"operations": operations})
```

---

## TOOL 3: `godotiq_script_ops` (A06) — Intelligent Script Editing

**Python-side tool** — reads/writes files directly from the filesystem. No addon needed. But uses Phase 1's `validate` tool for convention checking before writes.

### 4 modes:
- `read` — read file content
- `write` — write entire file (with optional validation)
- `patch` — find-and-replace (safest, like GDAI's edit_file but with validation)
- `create` — create new file

### Python MCP tool: `src/godotiq/tools/bridge/script_ops.py`

```python
"""A06 — godotiq_script_ops: Intelligent script reading/writing/patching.

Modes:
  - read: Read file content
  - write: Write entire file with optional convention validation
  - patch: Find-and-replace (safest mode, GDAI-inspired)
  - create: Create new file

Patch mode is the most important — the agent modifies only what changes,
and GodotIQ validates conventions before writing.
"""

from __future__ import annotations

import os
from pathlib import Path

TOOL_NAME = "godotiq_script_ops"
TOOL_DESCRIPTION = (
    "Read, write, patch, or create GDScript files. "
    "Patch mode (find-and-replace) is the safest way to modify scripts. "
    "Supports validate_before_write to check conventions before writing. "
    "Use dry_run=true to preview validation without writing. "
    "Works without addon connection (filesystem-based)."
)


def _get_project_root() -> str:
    """Get the Godot project root from environment."""
    return os.environ.get("GODOTIQ_PROJECT_ROOT", ".")


def _resolve_path(path: str) -> Path:
    """Resolve a res:// or relative path to absolute filesystem path."""
    project_root = _get_project_root()
    if path.startswith("res://"):
        path = path[6:]  # strip res://
    return Path(project_root) / path


def _is_protected(path: str) -> bool:
    """Check if path is protected (should not be modified)."""
    protected_patterns = [
        "project.godot",
        ".godot/",
        ".godot\\",
        "*.import",
        "saves/",
    ]
    for pattern in protected_patterns:
        if pattern.endswith("/") or pattern.endswith("\\"):
            if pattern.rstrip("/\\") in path:
                return True
        elif pattern.startswith("*"):
            if path.endswith(pattern[1:]):
                return True
        elif pattern in path:
            return True
    return False


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.

    Args:
        op: "read", "write", "patch", or "create"
        path: File path (res:// or relative to project root)
        content: File content (for write/create)
        patches: List of {"search": str, "replace": str} dicts (for patch mode)
        validate_before_write: Check conventions before writing (default True)
        auto_fix: Auto-fix trivial issues like type hints (default False)
        dry_run: Validate only, don't write (default False)

    Returns:
        Dict with status, content (for read), validation_issues, patches_applied, etc.
    """
    if not path:
        return {"status": "ERROR", "error": "No path provided"}

    resolved = _resolve_path(path)

    if op == "read":
        if not resolved.exists():
            return {"status": "ERROR", "error": f"File not found: {path}"}
        text = resolved.read_text(encoding="utf-8")
        return {
            "status": "OK",
            "path": path,
            "content": text,
            "lines": text.count("\n") + 1,
            "size_bytes": len(text.encode("utf-8")),
        }

    elif op == "patch":
        if not resolved.exists():
            return {"status": "ERROR", "error": f"File not found: {path}"}
        if _is_protected(path):
            return {"status": "BLOCKED", "error": f"File is protected: {path}"}
        if not patches:
            return {"status": "ERROR", "error": "No patches provided"}

        text = resolved.read_text(encoding="utf-8")
        applied = []
        failed = []

        for patch in patches:
            search = patch.get("search", "")
            replace = patch.get("replace", "")
            if not search:
                failed.append({"search": search, "error": "Empty search string"})
                continue
            if search not in text:
                failed.append({"search": search[:80], "error": "Not found in file"})
                continue
            # Check for multiple occurrences
            count = text.count(search)
            if count > 1:
                failed.append({
                    "search": search[:80],
                    "error": f"Found {count} occurrences. Search string must be unique.",
                })
                continue
            text = text.replace(search, replace, 1)
            applied.append({
                "search": search[:80],
                "replace": replace[:80],
                "line": _find_line_number(resolved.read_text(encoding="utf-8"), search),
            })

        if failed:
            return {
                "status": "PARTIAL" if applied else "ERROR",
                "applied": applied,
                "failed": failed,
                "written": False,
            }

        # Optionally validate
        validation_issues = []
        if validate_before_write:
            validation_issues = _validate_gdscript(text, path)

        if dry_run:
            return {
                "status": "DRY_RUN",
                "applied": applied,
                "validation_issues": validation_issues,
                "written": False,
            }

        # Write
        resolved.write_text(text, encoding="utf-8")
        return {
            "status": "OK",
            "applied": applied,
            "validation_issues": validation_issues,
            "written": True,
        }

    elif op == "write":
        if _is_protected(path):
            return {"status": "BLOCKED", "error": f"File is protected: {path}"}
        if not content:
            return {"status": "ERROR", "error": "No content provided"}

        validation_issues = []
        if validate_before_write:
            validation_issues = _validate_gdscript(content, path)

        if dry_run:
            return {
                "status": "DRY_RUN",
                "validation_issues": validation_issues,
                "written": False,
            }

        resolved.parent.mkdir(parents=True, exist_ok=True)
        resolved.write_text(content, encoding="utf-8")
        return {
            "status": "OK",
            "path": path,
            "lines": content.count("\n") + 1,
            "validation_issues": validation_issues,
            "written": True,
        }

    elif op == "create":
        if resolved.exists():
            return {"status": "ERROR", "error": f"File already exists: {path}"}
        if _is_protected(path):
            return {"status": "BLOCKED", "error": f"Path is protected: {path}"}
        if not content:
            return {"status": "ERROR", "error": "No content provided"}

        resolved.parent.mkdir(parents=True, exist_ok=True)
        resolved.write_text(content, encoding="utf-8")
        return {
            "status": "OK",
            "path": path,
            "created": True,
            "lines": content.count("\n") + 1,
        }

    else:
        return {"status": "ERROR", "error": f"Unknown operation: {op}"}


def _find_line_number(text: str, search: str) -> int:
    """Find the line number where search string starts."""
    idx = text.find(search)
    if idx == -1:
        return -1
    return text[:idx].count("\n") + 1


def _validate_gdscript(content: str, path: str) -> list[dict]:
    """Basic GDScript convention validation.

    This is a simplified version. For full validation, use the godotiq_validate tool.
    """
    issues = []
    lines = content.split("\n")

    for i, line in enumerate(lines, 1):
        stripped = line.strip()

        # Check for var without type hint (simple heuristic)
        if stripped.startswith("var ") and ":" not in stripped and "=" in stripped:
            if not stripped.startswith("var _") and "@onready" not in lines[i-2] if i > 1 else True:
                # Skip @export and @onready which often have type inference
                issues.append({
                    "severity": "WARN",
                    "rule": "type_hint",
                    "line": i,
                    "detail": f"Consider adding type hint: {stripped[:60]}",
                })

    return issues
```

---

## TOOL 4: `godotiq_file_ops` (A07) — Filesystem Operations

**Python-side tool** — complete filesystem operations within the Godot project. Includes search with context lines (grep-like), tree listing with type filtering, and protected file checks.

### Python MCP tool: `src/godotiq/tools/bridge/file_ops.py`

```python
"""A07 — godotiq_file_ops: Filesystem operations within the Godot project.

Operations: list, read, write, move, delete, search (grep-like), tree.
Respects protected_files from .godotiq.json.
"""

from __future__ import annotations

import os
import fnmatch
from pathlib import Path

TOOL_NAME = "godotiq_file_ops"
TOOL_DESCRIPTION = (
    "File system operations within the Godot project: list, read, write, move, "
    "delete, search (text search with context lines), tree (directory listing with "
    "type filtering). Respects protected files. "
    "Works without addon connection (filesystem-based)."
)

# File type filters (inspired by GDAI's proven pattern)
TYPE_FILTERS: dict[str, list[str]] = {
    "scenes": [".tscn", ".scn"],
    "scripts": [".gd"],
    "images": [".png", ".jpg", ".jpeg", ".svg", ".webp", ".bmp", ".tga"],
    "audio": [".wav", ".ogg", ".mp3"],
    "fonts": [".ttf", ".otf", ".woff", ".woff2"],
    "models": [".glb", ".gltf", ".obj", ".fbx"],
    "shaders": [".gdshader", ".shader"],
    "resources": [".tres", ".res"],
}

EXCLUDE_DIRS = {".godot", ".git", "__pycache__", "node_modules", ".import"}


def _get_project_root() -> str:
    return os.environ.get("GODOTIQ_PROJECT_ROOT", ".")


def _resolve(path: str) -> Path:
    root = _get_project_root()
    if path.startswith("res://"):
        path = path[6:]
    return Path(root) / path


def _is_protected(path: str) -> bool:
    protected = ["project.godot", ".godot/", "*.import", "saves/"]
    for p in protected:
        if p.endswith("/") and p.rstrip("/") in path:
            return True
        elif p.startswith("*") and path.endswith(p[1:]):
            return True
        elif p in path:
            return True
    return False


def _matches_type_filter(filename: str, filter_name: str) -> bool:
    if filter_name == "all" or not filter_name:
        return True
    exts = TYPE_FILTERS.get(filter_name, [])
    return any(filename.endswith(ext) for ext in exts)


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.

    Args:
        op: "list", "read", "write", "move", "delete", "search", "tree"
        path: File or directory path (res:// or relative)
        query: Search query (for search op)
        filter: Type filter: scenes, scripts, images, audio, fonts, models, shaders, resources, all
        recursive: Recurse into subdirectories (for list)
        context_lines: Lines of context around search matches (for search)
        max_depth: Max directory depth (for tree)
        show_sizes: Include file sizes (for tree)
        content: Content to write (for write)
        destination: Destination path (for move)

    Returns:
        Dict with operation results.
    """
    root = _get_project_root()

    if op == "list":
        target = _resolve(path) if path else Path(root)
        if not target.exists():
            return {"status": "ERROR", "error": f"Path not found: {path}"}
        if not target.is_dir():
            return {"status": "ERROR", "error": f"Not a directory: {path}"}

        entries = []
        for item in sorted(target.iterdir()):
            if item.name in EXCLUDE_DIRS:
                continue
            if item.name.startswith("."):
                continue
            if item.is_file() and not _matches_type_filter(item.name, filter):
                continue
            entry = {
                "name": item.name,
                "type": "dir" if item.is_dir() else "file",
            }
            if item.is_file() and show_sizes:
                entry["size"] = item.stat().st_size
            entries.append(entry)

            if recursive and item.is_dir():
                for sub in sorted(item.rglob("*")):
                    if any(ex in str(sub) for ex in EXCLUDE_DIRS):
                        continue
                    if sub.is_file() and not _matches_type_filter(sub.name, filter):
                        continue
                    rel = str(sub.relative_to(target))
                    entries.append({
                        "name": rel,
                        "type": "dir" if sub.is_dir() else "file",
                    })

        return {"status": "OK", "path": path, "entries": entries, "count": len(entries)}

    elif op == "read":
        resolved = _resolve(path)
        if not resolved.exists():
            return {"status": "ERROR", "error": f"File not found: {path}"}
        try:
            text = resolved.read_text(encoding="utf-8")
        except UnicodeDecodeError:
            return {"status": "ERROR", "error": f"Binary file, cannot read as text: {path}"}
        return {
            "status": "OK",
            "path": path,
            "content": text,
            "lines": text.count("\n") + 1,
        }

    elif op == "write":
        if _is_protected(path):
            return {"status": "BLOCKED", "error": f"Protected file: {path}"}
        resolved = _resolve(path)
        resolved.parent.mkdir(parents=True, exist_ok=True)
        resolved.write_text(content, encoding="utf-8")
        return {"status": "OK", "path": path, "written": True}

    elif op == "move":
        if _is_protected(path):
            return {"status": "BLOCKED", "error": f"Protected file: {path}"}
        src = _resolve(path)
        dst = _resolve(destination)
        if not src.exists():
            return {"status": "ERROR", "error": f"Source not found: {path}"}
        dst.parent.mkdir(parents=True, exist_ok=True)
        src.rename(dst)
        return {"status": "OK", "from": path, "to": destination}

    elif op == "delete":
        if _is_protected(path):
            return {"status": "BLOCKED", "error": f"Protected file: {path}"}
        resolved = _resolve(path)
        if not resolved.exists():
            return {"status": "ERROR", "error": f"Not found: {path}"}
        if resolved.is_dir():
            import shutil
            shutil.rmtree(resolved)
        else:
            resolved.unlink()
        return {"status": "OK", "deleted": path}

    elif op == "search":
        if not query:
            return {"status": "ERROR", "error": "No search query provided"}
        results = []
        root_path = Path(root)
        for filepath in sorted(root_path.rglob("*")):
            if not filepath.is_file():
                continue
            if any(ex in str(filepath) for ex in EXCLUDE_DIRS):
                continue
            if not _matches_type_filter(filepath.name, filter):
                continue
            try:
                text = filepath.read_text(encoding="utf-8")
            except (UnicodeDecodeError, PermissionError):
                continue
            lines = text.split("\n")
            for i, line in enumerate(lines):
                if query.lower() in line.lower():
                    rel = str(filepath.relative_to(root_path))
                    context_before = lines[max(0, i - context_lines):i]
                    context_after = lines[i + 1:min(len(lines), i + 1 + context_lines)]
                    results.append({
                        "file": rel,
                        "line": i + 1,
                        "match": line.strip()[:120],
                        "context_before": [l.strip()[:100] for l in context_before],
                        "context_after": [l.strip()[:100] for l in context_after],
                    })
            if len(results) >= 50:  # cap results
                break

        return {"status": "OK", "query": query, "matches": results, "count": len(results)}

    elif op == "tree":
        target = _resolve(path) if path else Path(root)
        if not target.exists():
            return {"status": "ERROR", "error": f"Path not found: {path}"}

        tree_lines = []
        _build_tree_lines(target, "", max_depth, 0, filter, show_sizes, tree_lines)

        return {"status": "OK", "path": path or ".", "tree": tree_lines}

    return {"status": "ERROR", "error": f"Unknown operation: {op}"}


def _build_tree_lines(
    dir_path: Path, prefix: str, max_depth: int, current_depth: int,
    filter_name: str, show_sizes: bool, out: list,
) -> None:
    if current_depth > max_depth:
        return
    try:
        items = sorted(dir_path.iterdir(), key=lambda x: (x.is_file(), x.name))
    except PermissionError:
        return
    for item in items:
        if item.name in EXCLUDE_DIRS or item.name.startswith("."):
            continue
        if item.is_file() and not _matches_type_filter(item.name, filter_name):
            continue
        entry = f"{prefix}{item.name}"
        if item.is_dir():
            entry += "/"
        if show_sizes and item.is_file():
            size = item.stat().st_size
            if size > 1024 * 1024:
                entry += f" ({size / 1024 / 1024:.1f}MB)"
            elif size > 1024:
                entry += f" ({size / 1024:.0f}KB)"
        out.append(entry)
        if item.is_dir() and current_depth < max_depth:
            _build_tree_lines(item, prefix + "  ", max_depth, current_depth + 1, filter_name, show_sizes, out)
```

---

## PART 5: Register Tools in MCP Server

### Modify: `src/godotiq/__main__.py` (or tool registration file)

Register the 4 new tools following the same pattern as Sprint 4a tools. The functions to register:

1. `godotiq_scene_tree` → `scene_tree()` from `src/godotiq/tools/bridge/scene_tree.py`
2. `godotiq_node_ops` → `node_ops()` from `src/godotiq/tools/bridge/node_ops.py`
3. `godotiq_script_ops` → `script_ops()` from `src/godotiq/tools/bridge/script_ops.py`
4. `godotiq_file_ops` → `file_ops()` from `src/godotiq/tools/bridge/file_ops.py`

**Error handling pattern** (same as Sprint 4a):
```python
from godotiq.bridge.connection import BridgeConnectionError, BridgeTimeoutError, BridgeError

# For bridge tools (scene_tree, node_ops):
try:
    result = await scene_tree(...)
    return result
except BridgeConnectionError as e:
    return {"error": str(e), "hint": "Enable the GodotIQ addon in Godot editor."}
except BridgeTimeoutError as e:
    return {"error": str(e)}
except BridgeError as e:
    return {"error": f"[{e.code}] {e.error_message}"}

# For filesystem tools (script_ops, file_ops): no bridge errors, they work offline
```

---

## PART 6: Tests

### `tests/test_bridge/test_scene_tree.py`

```python
"""Tests for scene_tree tool."""
import pytest
from unittest.mock import AsyncMock, patch
from godotiq.tools.bridge.scene_tree import scene_tree

class TestSceneTreeTool:
    @pytest.mark.asyncio
    async def test_default_params(self):
        mock_bridge = AsyncMock()
        mock_bridge.request = AsyncMock(return_value={
            "root": "Main", "total_nodes": 50, "returned_nodes": 20, "tree": [],
        })
        with patch("godotiq.tools.bridge.scene_tree.get_bridge", return_value=mock_bridge):
            result = await scene_tree()
            mock_bridge.request.assert_called_once_with("scene_tree", params={
                "root": "", "depth": 3, "filter_type": "", "include": ["transform", "script", "groups", "visibility"],
            })

    @pytest.mark.asyncio
    async def test_depth_clamping(self):
        mock_bridge = AsyncMock()
        mock_bridge.request = AsyncMock(return_value={"tree": []})
        with patch("godotiq.tools.bridge.scene_tree.get_bridge", return_value=mock_bridge):
            await scene_tree(depth=100)
            params = mock_bridge.request.call_args[1]["params"]
            assert params["depth"] == 10  # clamped to max 10

            await scene_tree(depth=-5)
            params = mock_bridge.request.call_args[1]["params"]
            assert params["depth"] == 1  # clamped to min 1
```

### `tests/test_bridge/test_node_ops.py`

```python
"""Tests for node_ops tool."""
import pytest
from unittest.mock import AsyncMock, patch
from godotiq.tools.bridge.node_ops import node_ops

class TestNodeOpsTool:
    @pytest.mark.asyncio
    async def test_empty_operations(self):
        result = await node_ops([])
        assert "error" in result

    @pytest.mark.asyncio
    async def test_move_operation(self):
        mock_bridge = AsyncMock()
        mock_bridge.request = AsyncMock(return_value={
            "results": [{"op": "move", "node": "Shelf", "status": "OK"}],
            "scene_modified": True, "undo_available": True,
        })
        with patch("godotiq.tools.bridge.node_ops.get_bridge", return_value=mock_bridge):
            result = await node_ops([
                {"op": "move", "node": "Shelf", "position": [1.0, 0, -0.5]},
            ])
            assert result["results"][0]["status"] == "OK"
            assert result["undo_available"] is True

    @pytest.mark.asyncio
    async def test_batch_operations(self):
        mock_bridge = AsyncMock()
        mock_bridge.request = AsyncMock(return_value={
            "results": [
                {"op": "move", "status": "OK"},
                {"op": "scale", "status": "OK"},
                {"op": "set_property", "status": "OK"},
            ],
            "scene_modified": True, "undo_available": True,
        })
        with patch("godotiq.tools.bridge.node_ops.get_bridge", return_value=mock_bridge):
            result = await node_ops([
                {"op": "move", "node": "A", "position": [1, 0, 0]},
                {"op": "scale", "node": "B", "scale": [0.5, 0.5, 0.5]},
                {"op": "set_property", "node": "C", "property": "visible", "value": False},
            ])
            assert len(result["results"]) == 3
```

### `tests/test_bridge/test_script_ops.py`

```python
"""Tests for script_ops tool."""
import pytest
import tempfile
import os
from pathlib import Path
from godotiq.tools.bridge.script_ops import script_ops


@pytest.fixture
def temp_project(tmp_path):
    """Create a temporary Godot project structure."""
    scripts = tmp_path / "scripts"
    scripts.mkdir()
    test_file = scripts / "test_script.gd"
    test_file.write_text(
        'extends Node\n\nvar count = 0\n\nfunc _ready():\n\tpass\n\n'
        'func do_something(id: String):\n\tprint(id)\n',
        encoding="utf-8",
    )
    os.environ["GODOTIQ_PROJECT_ROOT"] = str(tmp_path)
    yield tmp_path
    os.environ.pop("GODOTIQ_PROJECT_ROOT", None)


class TestScriptOpsRead:
    @pytest.mark.asyncio
    async def test_read_existing(self, temp_project):
        result = await script_ops(op="read", path="scripts/test_script.gd")
        assert result["status"] == "OK"
        assert "extends Node" in result["content"]
        assert result["lines"] > 0

    @pytest.mark.asyncio
    async def test_read_not_found(self, temp_project):
        result = await script_ops(op="read", path="scripts/nonexistent.gd")
        assert result["status"] == "ERROR"

    @pytest.mark.asyncio
    async def test_read_res_prefix(self, temp_project):
        result = await script_ops(op="read", path="res://scripts/test_script.gd")
        assert result["status"] == "OK"


class TestScriptOpsPatch:
    @pytest.mark.asyncio
    async def test_patch_success(self, temp_project):
        result = await script_ops(
            op="patch",
            path="scripts/test_script.gd",
            patches=[{
                "search": "func do_something(id: String):",
                "replace": "func do_something(id: String, priority: int = 0):",
            }],
            validate_before_write=False,
        )
        assert result["status"] == "OK"
        assert result["written"] is True
        # Verify file content
        content = (temp_project / "scripts" / "test_script.gd").read_text()
        assert "priority: int = 0" in content

    @pytest.mark.asyncio
    async def test_patch_not_found(self, temp_project):
        result = await script_ops(
            op="patch",
            path="scripts/test_script.gd",
            patches=[{"search": "THIS_DOESNT_EXIST", "replace": "something"}],
            validate_before_write=False,
        )
        assert result["status"] == "ERROR"
        assert result["written"] is False

    @pytest.mark.asyncio
    async def test_patch_dry_run(self, temp_project):
        result = await script_ops(
            op="patch",
            path="scripts/test_script.gd",
            patches=[{"search": "var count = 0", "replace": "var count: int = 0"}],
            dry_run=True,
        )
        assert result["status"] == "DRY_RUN"
        assert result["written"] is False
        # Original file unchanged
        content = (temp_project / "scripts" / "test_script.gd").read_text()
        assert "var count = 0" in content

    @pytest.mark.asyncio
    async def test_patch_protected_file(self, temp_project):
        (temp_project / "project.godot").write_text("[gd_resource]", encoding="utf-8")
        result = await script_ops(
            op="patch",
            path="project.godot",
            patches=[{"search": "gd_resource", "replace": "hacked"}],
        )
        assert result["status"] == "BLOCKED"


class TestScriptOpsWrite:
    @pytest.mark.asyncio
    async def test_write_new_content(self, temp_project):
        result = await script_ops(
            op="write",
            path="scripts/test_script.gd",
            content="extends Node\n\nvar count: int = 0\n",
            validate_before_write=False,
        )
        assert result["status"] == "OK"
        assert result["written"] is True


class TestScriptOpsCreate:
    @pytest.mark.asyncio
    async def test_create_new_file(self, temp_project):
        result = await script_ops(
            op="create",
            path="scripts/new_script.gd",
            content="extends Node\n\nfunc _ready():\n\tpass\n",
        )
        assert result["status"] == "OK"
        assert result["created"] is True
        assert (temp_project / "scripts" / "new_script.gd").exists()

    @pytest.mark.asyncio
    async def test_create_existing_fails(self, temp_project):
        result = await script_ops(
            op="create",
            path="scripts/test_script.gd",
            content="something",
        )
        assert result["status"] == "ERROR"
```

### `tests/test_bridge/test_file_ops.py`

```python
"""Tests for file_ops tool."""
import pytest
import os
from pathlib import Path
from godotiq.tools.bridge.file_ops import file_ops


@pytest.fixture
def temp_project(tmp_path):
    """Create a temporary Godot project."""
    # Create structure
    (tmp_path / "scripts").mkdir()
    (tmp_path / "scenes").mkdir()
    (tmp_path / "assets" / "models").mkdir(parents=True)

    (tmp_path / "scripts" / "player.gd").write_text("extends Node\n# player\n")
    (tmp_path / "scripts" / "enemy.gd").write_text("extends Node\n# enemy\nfunc attack():\n\tpass\n")
    (tmp_path / "scenes" / "main.tscn").write_text("[gd_scene]\n")
    (tmp_path / "assets" / "models" / "cube.glb").write_bytes(b"fake glb")
    (tmp_path / "project.godot").write_text("[gd_resource]\n")

    os.environ["GODOTIQ_PROJECT_ROOT"] = str(tmp_path)
    yield tmp_path
    os.environ.pop("GODOTIQ_PROJECT_ROOT", None)


class TestFileOpsList:
    @pytest.mark.asyncio
    async def test_list_root(self, temp_project):
        result = await file_ops(op="list")
        assert result["status"] == "OK"
        names = [e["name"] for e in result["entries"]]
        assert "scripts" in names
        assert "scenes" in names

    @pytest.mark.asyncio
    async def test_list_with_filter(self, temp_project):
        result = await file_ops(op="list", path="scripts", filter="scripts")
        assert result["status"] == "OK"
        assert all(e["name"].endswith(".gd") for e in result["entries"] if e["type"] == "file")


class TestFileOpsSearch:
    @pytest.mark.asyncio
    async def test_search_text(self, temp_project):
        result = await file_ops(op="search", query="attack", filter="scripts")
        assert result["status"] == "OK"
        assert result["count"] >= 1
        assert "enemy.gd" in result["matches"][0]["file"]

    @pytest.mark.asyncio
    async def test_search_case_insensitive(self, temp_project):
        result = await file_ops(op="search", query="ATTACK", filter="scripts")
        assert result["count"] >= 1

    @pytest.mark.asyncio
    async def test_search_with_context(self, temp_project):
        result = await file_ops(op="search", query="attack", context_lines=1)
        match = result["matches"][0]
        assert "context_before" in match
        assert "context_after" in match


class TestFileOpsTree:
    @pytest.mark.asyncio
    async def test_tree(self, temp_project):
        result = await file_ops(op="tree", max_depth=2)
        assert result["status"] == "OK"
        assert len(result["tree"]) > 0


class TestFileOpsProtection:
    @pytest.mark.asyncio
    async def test_cannot_delete_protected(self, temp_project):
        result = await file_ops(op="delete", path="project.godot")
        assert result["status"] == "BLOCKED"
```

---

## PART 7: Modify `godotiq_server.gd`

In the existing `godotiq_server.gd`, add these handlers to the `_dispatch()` function:

```gdscript
# Add to the match block in _dispatch():
"scene_tree":
    _handle_scene_tree(peer_id, id, params)
"node_ops":
    _handle_node_ops(peer_id, id, params)
```

Then add all the implementation functions from Tool 1 and Tool 2 above to `godotiq_server.gd`.

**NOTE on EngineDebugger prefix stripping bug (Sprint 4a fix):** Remember that `EngineDebugger.register_message_capture("godotiq", callable)` strips the `"godotiq:"` prefix before passing to the handler. So `_on_debugger_message` receives `"screenshot"` not `"godotiq:screenshot"`. This was already fixed in Sprint 4a — don't re-introduce the bug.

---

## Verification Checklist

1. **Regression**: `pytest tests/ -v` → ALL 492 Phase 1+4a tests still pass
2. **New tests**: `pytest tests/test_bridge/test_scene_tree.py tests/test_bridge/test_node_ops.py tests/test_bridge/test_script_ops.py tests/test_bridge/test_file_ops.py -v` → ALL pass
3. **Import checks**:
   ```
   python -c "from godotiq.tools.bridge.scene_tree import scene_tree; print('OK')"
   python -c "from godotiq.tools.bridge.node_ops import node_ops; print('OK')"
   python -c "from godotiq.tools.bridge.script_ops import script_ops; print('OK')"
   python -c "from godotiq.tools.bridge.file_ops import file_ops; print('OK')"
   ```
4. **GDScript**: Check `godotiq_server.gd` for Python syntax leaks
5. **Total test count**: should be 492 + new tests = ~520+

### Manual Godot testing (Nash will do separately):
- Copy updated addon to PFT project
- scene_tree: verify returns correct tree structure
- node_ops: move a node, verify Ctrl+Z works
- script_ops patch: edit a script, verify content changed
- file_ops search: search for "order" in scripts, verify matches

---

## Summary of deliverables

### Files to CREATE:
| # | Path | Purpose |
|---|------|---------|
| 1 | `src/godotiq/tools/bridge/scene_tree.py` | MCP tool A02 |
| 2 | `src/godotiq/tools/bridge/node_ops.py` | MCP tool A05 |
| 3 | `src/godotiq/tools/bridge/script_ops.py` | MCP tool A06 |
| 4 | `src/godotiq/tools/bridge/file_ops.py` | MCP tool A07 |
| 5 | `tests/test_bridge/test_scene_tree.py` | Tests |
| 6 | `tests/test_bridge/test_node_ops.py` | Tests |
| 7 | `tests/test_bridge/test_script_ops.py` | Tests |
| 8 | `tests/test_bridge/test_file_ops.py` | Tests |

### Files to MODIFY:
| Path | Change |
|------|--------|
| `godot-addon/addons/godotiq/godotiq_server.gd` | Add `scene_tree` + `node_ops` handlers + helper functions |
| `src/godotiq/__main__.py` (or tool registration) | Register 4 new tools |
