diff --git a/src/godotiq/tools/bridge/file_ops.py b/src/godotiq/tools/bridge/file_ops.py
new file mode 100644
index 0000000..fdf0635
--- /dev/null
+++ b/src/godotiq/tools/bridge/file_ops.py
@@ -0,0 +1,322 @@
+"""File operations tool — filesystem operations within the Godot project."""
+
+from __future__ import annotations
+
+import os
+import shutil
+from pathlib import Path
+
+TYPE_FILTERS: dict[str, set[str]] = {
+    "scenes": {".tscn", ".scn"},
+    "scripts": {".gd"},
+    "images": {".png", ".jpg", ".jpeg", ".webp", ".svg", ".bmp", ".tga"},
+    "audio": {".wav", ".ogg", ".mp3"},
+    "fonts": {".ttf", ".otf", ".woff", ".woff2"},
+    "models": {".glb", ".gltf", ".obj", ".fbx", ".dae"},
+    "shaders": {".gdshader"},
+    "resources": {".tres", ".res"},
+}
+
+EXCLUDED_DIRS: set[str] = {".godot", ".git", "__pycache__", "node_modules", ".import"}
+
+MAX_SEARCH_MATCHES: int = 50
+
+
+def _resolve(path: str) -> Path:
+    """Resolve a res:// or relative path to an absolute Path within the project root.
+
+    Raises ValueError if the resolved path is outside the project root.
+    """
+    root_str = os.environ.get("GODOTIQ_PROJECT_ROOT")
+    if not root_str:
+        raise ValueError("GODOTIQ_PROJECT_ROOT not set")
+
+    root = Path(root_str).resolve()
+
+    if path.startswith("res://"):
+        path = path[len("res://"):]
+
+    resolved = (root / path).resolve()
+
+    try:
+        resolved.relative_to(root)
+    except ValueError:
+        raise ValueError(f"Path is outside the project root: {path}")
+
+    return resolved
+
+
+def _is_protected(path: Path) -> bool:
+    """Check if a path matches protected file patterns."""
+    if path.name == "project.godot":
+        return True
+    if ".godot" in path.parts:
+        return True
+    if path.suffix == ".import":
+        return True
+    if "saves" in path.parts:
+        return True
+    return False
+
+
+def _matches_type_filter(filename: str, filter_name: str) -> bool:
+    """Check if a filename matches the given type filter."""
+    if filter_name == "all":
+        return True
+    extensions = TYPE_FILTERS.get(filter_name)
+    if extensions is None:
+        return False
+    return Path(filename).suffix.lower() in extensions
+
+
+def _get_root() -> Path:
+    """Get the resolved project root path."""
+    root_str = os.environ.get("GODOTIQ_PROJECT_ROOT")
+    if not root_str:
+        raise ValueError("GODOTIQ_PROJECT_ROOT not set")
+    return Path(root_str).resolve()
+
+
+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 within the Godot project."""
+    try:
+        if op == "list":
+            return _op_list(path, filter, recursive)
+        elif op == "read":
+            return _op_read(path)
+        elif op == "write":
+            return _op_write(path, content)
+        elif op == "move":
+            return _op_move(path, destination)
+        elif op == "delete":
+            return _op_delete(path)
+        elif op == "search":
+            return _op_search(path, query, filter, context_lines)
+        elif op == "tree":
+            return _op_tree(path, max_depth, show_sizes)
+        else:
+            return {"error": f"Unknown operation: {op}"}
+    except ValueError as e:
+        return {"error": str(e)}
+
+
+def _op_list(path: str, type_filter: str, recursive: bool) -> dict:
+    """List directory entries."""
+    root = _get_root()
+    if path:
+        target = _resolve(path)
+    else:
+        target = root
+
+    if not target.is_dir():
+        return {"error": f"Directory not found: {path or '.'}"}
+
+    entries: list[dict] = []
+
+    if recursive:
+        for dirpath, dirnames, filenames in os.walk(target):
+            # Filter out excluded directories in-place
+            dirnames[:] = [d for d in dirnames if d not in EXCLUDED_DIRS]
+            for fname in filenames:
+                if _matches_type_filter(fname, type_filter):
+                    full = Path(dirpath) / fname
+                    rel = full.relative_to(root)
+                    entries.append({"name": str(rel), "type": "file"})
+    else:
+        for entry in sorted(target.iterdir()):
+            if entry.name in EXCLUDED_DIRS:
+                continue
+            if entry.is_dir():
+                entries.append({"name": entry.name, "type": "dir"})
+            elif entry.is_file() and _matches_type_filter(entry.name, type_filter):
+                entries.append({"name": entry.name, "type": "file"})
+
+    return {"op": "list", "path": path or ".", "entries": entries}
+
+
+def _op_read(path: str) -> dict:
+    """Read a text file."""
+    resolved = _resolve(path)
+
+    if not resolved.is_file():
+        return {"error": f"File not found: {path}"}
+
+    try:
+        text = resolved.read_text(encoding="utf-8")
+    except UnicodeDecodeError:
+        return {"error": f"Cannot read binary file: {path}"}
+
+    line_count = text.count("\n")
+    if text and not text.endswith("\n"):
+        line_count += 1
+
+    return {
+        "op": "read",
+        "path": path,
+        "content": text,
+        "lines": line_count,
+        "size": resolved.stat().st_size,
+    }
+
+
+def _op_write(path: str, content: str) -> dict:
+    """Write content to a file."""
+    resolved = _resolve(path)
+
+    if _is_protected(resolved):
+        return {"error": f"Cannot modify protected file: {path}"}
+
+    created = not resolved.exists()
+    resolved.parent.mkdir(parents=True, exist_ok=True)
+    resolved.write_text(content, encoding="utf-8")
+
+    return {
+        "op": "write",
+        "path": path,
+        "size": resolved.stat().st_size,
+        "created": created,
+    }
+
+
+def _op_move(path: str, destination: str) -> dict:
+    """Move/rename a file."""
+    resolved_src = _resolve(path)
+    resolved_dst = _resolve(destination)
+
+    if _is_protected(resolved_src):
+        return {"error": f"Cannot modify protected file: {path}"}
+    if _is_protected(resolved_dst):
+        return {"error": f"Cannot modify protected file: {destination}"}
+
+    if not resolved_src.exists():
+        return {"error": f"File not found: {path}"}
+
+    resolved_dst.parent.mkdir(parents=True, exist_ok=True)
+    shutil.move(str(resolved_src), str(resolved_dst))
+
+    return {
+        "op": "move",
+        "source": path,
+        "destination": destination,
+        "success": True,
+    }
+
+
+def _op_delete(path: str) -> dict:
+    """Delete a file."""
+    resolved = _resolve(path)
+
+    if _is_protected(resolved):
+        return {"error": f"Cannot modify protected file: {path}"}
+
+    if not resolved.exists():
+        return {"error": f"File not found: {path}"}
+
+    resolved.unlink()
+
+    return {"op": "delete", "path": path, "success": True}
+
+
+def _op_search(path: str, query: str, type_filter: str, context_lines: int) -> dict:
+    """Search for text across files."""
+    root = _get_root()
+    if path:
+        target = _resolve(path)
+    else:
+        target = root
+
+    matches: list[dict] = []
+    capped = False
+
+    for dirpath, dirnames, filenames in os.walk(target):
+        dirnames[:] = [d for d in dirnames if d not in EXCLUDED_DIRS]
+
+        for fname in filenames:
+            if not _matches_type_filter(fname, type_filter):
+                continue
+
+            full = Path(dirpath) / fname
+            try:
+                text = full.read_text(encoding="utf-8")
+            except (UnicodeDecodeError, PermissionError):
+                continue
+
+            lines = text.splitlines()
+            rel = str(full.relative_to(root))
+
+            for i, line in enumerate(lines):
+                if query in line:
+                    before = lines[max(0, i - context_lines):i]
+                    after = lines[i + 1:i + 1 + context_lines]
+                    matches.append({
+                        "file": rel,
+                        "line": i + 1,
+                        "content": line,
+                        "context_before": before,
+                        "context_after": after,
+                    })
+                    if len(matches) >= MAX_SEARCH_MATCHES:
+                        capped = True
+                        break
+
+            if capped:
+                break
+        if capped:
+            break
+
+    return {
+        "op": "search",
+        "query": query,
+        "matches": matches,
+        "total_matches": len(matches),
+        "capped": capped,
+    }
+
+
+def _op_tree(path: str, max_depth: int, show_sizes: bool) -> dict:
+    """Build a directory tree structure."""
+    root = _get_root()
+    if path:
+        target = _resolve(path)
+    else:
+        target = root
+
+    def build_tree(dir_path: Path, depth: int) -> dict:
+        node: dict = {"name": dir_path.name, "type": "dir"}
+        if depth >= max_depth:
+            return node
+
+        children: list[dict] = []
+        try:
+            entries = sorted(dir_path.iterdir(), key=lambda p: (not p.is_dir(), p.name))
+        except PermissionError:
+            return node
+
+        for entry in entries:
+            if entry.name in EXCLUDED_DIRS:
+                continue
+            if entry.is_dir():
+                children.append(build_tree(entry, depth + 1))
+            elif entry.is_file():
+                child: dict = {"name": entry.name, "type": "file"}
+                if show_sizes:
+                    child["size"] = entry.stat().st_size
+                children.append(child)
+
+        node["children"] = children
+        return node
+
+    tree = build_tree(target, 0)
+
+    return {"op": "tree", "path": path or ".", "tree": tree}
diff --git a/tests/test_bridge/test_file_ops.py b/tests/test_bridge/test_file_ops.py
new file mode 100644
index 0000000..f98b27d
--- /dev/null
+++ b/tests/test_bridge/test_file_ops.py
@@ -0,0 +1,304 @@
+"""Tests for file_ops filesystem tool."""
+
+from __future__ import annotations
+
+import pytest
+
+from godotiq.tools.bridge.file_ops import file_ops, _resolve, _is_protected, _matches_type_filter
+
+
+@pytest.fixture
+def project(tmp_path, monkeypatch):
+    """Set up a fake Godot project directory with sample files."""
+    monkeypatch.setenv("GODOTIQ_PROJECT_ROOT", str(tmp_path))
+
+    # Create project.godot so it looks like a real project
+    (tmp_path / "project.godot").write_text("[gd_resource]")
+
+    # Create sample files
+    (tmp_path / "main.tscn").write_text("[gd_scene format=3]")
+    (tmp_path / "player.gd").write_text("extends Node\nfunc _ready():\n\tpass\n")
+    (tmp_path / "icon.png").write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 100)
+
+    # Create subdirectory structure
+    scripts_dir = tmp_path / "scripts"
+    scripts_dir.mkdir()
+    (scripts_dir / "enemy.gd").write_text(
+        "extends CharacterBody3D\nvar speed: float = 5.0\n"
+    )
+    (scripts_dir / "utils.gd").write_text(
+        "class_name Utils\nstatic func helper() -> void:\n\tpass\n"
+    )
+
+    scenes_dir = tmp_path / "scenes"
+    scenes_dir.mkdir()
+    (scenes_dir / "level1.tscn").write_text("[gd_scene format=3]")
+
+    # Create excluded directories
+    godot_dir = tmp_path / ".godot"
+    godot_dir.mkdir()
+    (godot_dir / "cache.bin").write_bytes(b"\x00" * 10)
+
+    git_dir = tmp_path / ".git"
+    git_dir.mkdir()
+    (git_dir / "config").write_text("[core]")
+
+    # Create saves dir (protected)
+    saves_dir = tmp_path / "saves"
+    saves_dir.mkdir()
+    (saves_dir / "save1.tres").write_text("[resource]")
+
+    return tmp_path
+
+
+class TestResolve:
+    """Path resolution and containment checks."""
+
+    async def test_res_prefix(self, project, monkeypatch):
+        """res:// paths resolve relative to project root."""
+        result = _resolve("res://player.gd")
+        assert result == project / "player.gd"
+
+    async def test_relative_path(self, project, monkeypatch):
+        """Relative paths resolve against project root."""
+        result = _resolve("scripts/enemy.gd")
+        assert result == project / "scripts" / "enemy.gd"
+
+    async def test_path_traversal_blocked(self, project, monkeypatch):
+        """Path traversal outside project root raises ValueError."""
+        with pytest.raises(ValueError, match="outside"):
+            _resolve("../../etc/passwd")
+
+
+class TestIsProtected:
+    """Protected file detection."""
+
+    def test_protected_files(self, project):
+        """Known protected patterns are correctly identified."""
+        root = project
+        assert _is_protected(root / "project.godot") is True
+        assert _is_protected(root / ".godot" / "cache.bin") is True
+        assert _is_protected(root / "something.import") is True
+        assert _is_protected(root / "saves" / "save1.tres") is True
+        # Not protected
+        assert _is_protected(root / "player.gd") is False
+        assert _is_protected(root / "main.tscn") is False
+
+
+class TestListOperation:
+    """The 'list' operation."""
+
+    async def test_list_root(self, project):
+        """Listing project root returns top-level entries."""
+        result = await file_ops(op="list")
+        assert "entries" in result
+        names = [e["name"] for e in result["entries"]]
+        assert "main.tscn" in names
+        assert "player.gd" in names
+
+    async def test_list_filter_scripts(self, project):
+        """Type filter narrows results to matching extensions."""
+        result = await file_ops(op="list", filter="scripts")
+        names = [e["name"] for e in result["entries"]]
+        assert all(n.endswith(".gd") for n in names if result["entries"][names.index(n)]["type"] == "file")
+
+    async def test_list_recursive(self, project):
+        """Recursive listing includes files in subdirectories."""
+        result = await file_ops(op="list", recursive=True, filter="scripts")
+        names = [e["name"] for e in result["entries"]]
+        assert "enemy.gd" in names or any("enemy.gd" in n for n in names)
+
+    async def test_list_excludes_system_dirs(self, project):
+        """System/hidden directories are automatically excluded."""
+        result = await file_ops(op="list")
+        names = [e["name"] for e in result["entries"]]
+        assert ".godot" not in names
+        assert ".git" not in names
+
+    async def test_list_missing_dir(self, project):
+        """Listing a nonexistent directory returns an error."""
+        result = await file_ops(op="list", path="nonexistent_dir")
+        assert "error" in result
+
+
+class TestReadOperation:
+    """The 'read' operation."""
+
+    async def test_read_text_file(self, project):
+        """Reading a text file returns its content."""
+        result = await file_ops(op="read", path="player.gd")
+        assert result["content"] == "extends Node\nfunc _ready():\n\tpass\n"
+        assert result["lines"] == 3
+
+    async def test_read_binary_rejected(self, project):
+        """Attempting to read a binary file returns an error."""
+        result = await file_ops(op="read", path="icon.png")
+        assert "error" in result
+        assert "binary" in result["error"].lower()
+
+    async def test_read_missing_file(self, project):
+        """Reading a nonexistent file returns an error."""
+        result = await file_ops(op="read", path="missing.gd")
+        assert "error" in result
+
+
+class TestWriteOperation:
+    """The 'write' operation."""
+
+    async def test_write_creates_file(self, project):
+        """Writing to a new path creates the file."""
+        result = await file_ops(op="write", path="new_file.cfg", content="[config]\nkey=value\n")
+        assert result["op"] == "write"
+        assert (project / "new_file.cfg").exists()
+        assert (project / "new_file.cfg").read_text() == "[config]\nkey=value\n"
+
+    async def test_write_protected_rejected(self, project):
+        """Writing to a protected file returns an error."""
+        result = await file_ops(op="write", path="project.godot", content="bad")
+        assert "error" in result
+        assert "protected" in result["error"].lower()
+
+    async def test_write_traversal_blocked(self, project):
+        """Write with path traversal is rejected."""
+        result = await file_ops(op="write", path="../../evil.txt", content="bad")
+        assert "error" in result
+
+
+class TestMoveOperation:
+    """The 'move' operation."""
+
+    async def test_move_renames_file(self, project):
+        """Moving a file to a new name renames it on disk."""
+        result = await file_ops(op="move", path="player.gd", destination="player_old.gd")
+        assert result.get("success") is True
+        assert not (project / "player.gd").exists()
+        assert (project / "player_old.gd").exists()
+
+    async def test_move_protected_source_rejected(self, project):
+        """Cannot move a protected source file."""
+        result = await file_ops(op="move", path="project.godot", destination="renamed.godot")
+        assert "error" in result
+
+    async def test_move_protected_destination_rejected(self, project):
+        """Cannot move into a protected destination path."""
+        result = await file_ops(op="move", path="player.gd", destination="saves/player.gd")
+        assert "error" in result
+
+    async def test_move_traversal_blocked(self, project):
+        """Path traversal is blocked for both source and destination."""
+        result = await file_ops(op="move", path="../../etc/passwd", destination="stolen.txt")
+        assert "error" in result
+        result2 = await file_ops(op="move", path="player.gd", destination="../../evil.gd")
+        assert "error" in result2
+
+
+class TestDeleteOperation:
+    """The 'delete' operation."""
+
+    async def test_delete_removes_file(self, project):
+        """Deleting an existing file removes it from disk."""
+        result = await file_ops(op="delete", path="player.gd")
+        assert result.get("success") is True
+        assert not (project / "player.gd").exists()
+
+    async def test_delete_protected_rejected(self, project):
+        """Cannot delete a protected file."""
+        result = await file_ops(op="delete", path="project.godot")
+        assert "error" in result
+
+    async def test_delete_traversal_blocked(self, project):
+        """Delete with path traversal is rejected."""
+        result = await file_ops(op="delete", path="../../etc/passwd")
+        assert "error" in result
+
+
+class TestSearchOperation:
+    """The 'search' operation."""
+
+    async def test_search_finds_matches(self, project):
+        """Search returns files containing the query string."""
+        result = await file_ops(op="search", query="extends")
+        assert len(result["matches"]) >= 2  # player.gd and enemy.gd
+
+    async def test_search_context_lines(self, project):
+        """Context lines are included around each match."""
+        result = await file_ops(op="search", query="_ready", context_lines=1)
+        match = result["matches"][0]
+        assert "context_before" in match
+        assert "context_after" in match
+
+    async def test_search_with_filter(self, project):
+        """Type filter restricts which files are searched."""
+        result = await file_ops(op="search", query="format=3", filter="scenes")
+        for m in result["matches"]:
+            assert m["file"].endswith(".tscn")
+
+    async def test_search_excludes_system_dirs(self, project):
+        """System directories are skipped during search."""
+        result = await file_ops(op="search", query="core")
+        for m in result["matches"]:
+            assert ".git" not in m["file"]
+            assert ".godot" not in m["file"]
+
+    async def test_search_max_matches_cap(self, project):
+        """Results are capped at 50 matches maximum."""
+        # Create many files with matching content
+        bulk_dir = project / "bulk"
+        bulk_dir.mkdir()
+        for i in range(60):
+            (bulk_dir / f"file_{i}.gd").write_text(f"extends Node  # file {i}\n")
+
+        result = await file_ops(op="search", query="extends Node")
+        assert len(result["matches"]) <= 50
+        assert result["capped"] is True
+
+
+class TestTreeOperation:
+    """The 'tree' operation."""
+
+    async def test_tree_with_depth(self, project):
+        """Tree respects max_depth parameter."""
+        result = await file_ops(op="tree", max_depth=1)
+        assert result["op"] == "tree"
+        assert "tree" in result
+        # At depth 1, should have top-level entries but not deeper
+        tree = result["tree"]
+        assert tree["type"] == "dir"
+
+    async def test_tree_with_sizes(self, project):
+        """File sizes are included when requested."""
+        result = await file_ops(op="tree", show_sizes=True)
+        tree = result["tree"]
+        # Find a file entry and check it has size
+        for child in tree.get("children", []):
+            if child["type"] == "file":
+                assert "size" in child
+                break
+
+    async def test_tree_excludes_system_dirs(self, project):
+        """System directories (.godot, .git, etc.) are excluded from tree."""
+        result = await file_ops(op="tree", max_depth=2)
+        tree = result["tree"]
+        child_names = [c["name"] for c in tree.get("children", [])]
+        assert ".godot" not in child_names
+        assert ".git" not in child_names
+
+
+class TestTypeFilters:
+    """Type filter matching logic."""
+
+    def test_scene_filter(self):
+        """Scene filter matches .tscn and .scn extensions."""
+        assert _matches_type_filter("level.tscn", "scenes") is True
+        assert _matches_type_filter("level.scn", "scenes") is True
+        assert _matches_type_filter("script.gd", "scenes") is False
+
+    def test_script_filter(self):
+        """Script filter matches .gd extension."""
+        assert _matches_type_filter("player.gd", "scripts") is True
+        assert _matches_type_filter("level.tscn", "scripts") is False
+
+    def test_all_filter(self):
+        """The 'all' filter matches any file."""
+        assert _matches_type_filter("anything.xyz", "all") is True
+        assert _matches_type_filter("script.gd", "all") is True
