diff --git a/src/godotiq/session.py b/src/godotiq/session.py
new file mode 100644
index 0000000..17afdc6
--- /dev/null
+++ b/src/godotiq/session.py
@@ -0,0 +1,164 @@
+"""GodotIQ session — unified project state for MCP tools.
+
+Bridges the parsers into a single cached state object. Created once
+at server startup, provides all cross-reference queries the tools need.
+"""
+
+from __future__ import annotations
+
+import os
+from collections import defaultdict
+from pathlib import Path
+
+from godotiq.cache.file_cache import FileCache
+from godotiq.config import load_config, GodotIQConfig
+from godotiq.parsers.gd_parser import parse_gd, GdScript
+from godotiq.parsers.project_index import scan_project, ProjectIndex
+from godotiq.parsers.scene_resolver import (
+    resolve_scene,
+    calculate_world_transforms,
+    ResolvedScene,
+    WorldNode,
+)
+from godotiq.parsers.tscn_parser import parse_tscn, TscnScene
+
+
+class GodotIQSession:
+    """Holds loaded project state. Created once, cached, refreshed on file changes."""
+
+    def __init__(self, project_root: str | Path) -> None:
+        self.project_root = Path(project_root)
+        self.cache = FileCache()
+        self.config: GodotIQConfig = GodotIQConfig()
+        self._index: ProjectIndex | None = None
+        self._gd_scripts: dict[str, GdScript] = {}
+        self._tscn_scenes: dict[str, TscnScene] = {}
+        self._resolved_scenes: dict[str, tuple[ResolvedScene, list[WorldNode]]] = {}
+
+    def load(self) -> None:
+        """Full project scan + parse all .gd files with autoload awareness."""
+        self._index = scan_project(self.project_root, self.cache)
+        autoload_names = list(self._index.autoloads.keys())
+        for script_path in self._index.scripts:
+            abs_path = self._resolve_res_path(script_path)
+            if abs_path.exists():
+                self._gd_scripts[script_path] = parse_gd(
+                    str(abs_path), known_autoloads=autoload_names
+                )
+        config_path = self.project_root / ".godotiq.json"
+        self.config = load_config(str(config_path))
+
+    def _resolve_res_path(self, res_path: str) -> Path:
+        """Convert a res:// path to an absolute filesystem path."""
+        relative = res_path.removeprefix("res://")
+        return self.project_root / relative
+
+    def get_script(self, res_path: str) -> GdScript | None:
+        """Return the parsed GdScript for the given res:// path, or None."""
+        return self._gd_scripts.get(res_path)
+
+    def get_scene(self, res_path: str) -> TscnScene | None:
+        """Lazy parse + cache a .tscn scene."""
+        if self._index and res_path in self._index.scenes:
+            return self._index.scenes[res_path]
+        if res_path in self._tscn_scenes:
+            return self._tscn_scenes[res_path]
+        abs_path = self._resolve_res_path(res_path)
+        if abs_path.exists():
+            scene = parse_tscn(str(abs_path))
+            self._tscn_scenes[res_path] = scene
+            return scene
+        return None
+
+    def resolve_scene_spatial(
+        self, res_path: str
+    ) -> tuple[ResolvedScene, list[WorldNode]] | None:
+        """Lazy resolve + cache scene spatial data."""
+        if res_path in self._resolved_scenes:
+            return self._resolved_scenes[res_path]
+        scene = self.get_scene(res_path)
+        if scene is None:
+            return None
+        resolved = resolve_scene(scene, self.project_root)
+        world_nodes = calculate_world_transforms(resolved)
+        result = (resolved, world_nodes)
+        self._resolved_scenes[res_path] = result
+        return result
+
+    def get_all_signal_definitions(self) -> dict[str, list[str]]:
+        """Map signal names to the scripts that define them."""
+        result: dict[str, list[str]] = defaultdict(list)
+        for path, gd in self._gd_scripts.items():
+            for sig in gd.signals:
+                result[sig.name].append(path)
+        return dict(result)
+
+    def get_all_signal_emissions(self) -> dict[str, list[str]]:
+        """Map signal names to the scripts that emit them."""
+        result: dict[str, list[str]] = defaultdict(list)
+        for path, gd in self._gd_scripts.items():
+            for em in gd.signal_emissions:
+                result[em.signal_name].append(path)
+        return dict(result)
+
+    def get_all_signal_connections(self) -> dict[str, list[str]]:
+        """Map signal source to the scripts that connect to them."""
+        result: dict[str, list[str]] = defaultdict(list)
+        for path, gd in self._gd_scripts.items():
+            for conn in gd.signal_connections:
+                result[conn.signal_source].append(path)
+        return dict(result)
+
+    def get_autoload_dependents(self, autoload_name: str) -> list[str]:
+        """Return list of res:// script paths referencing the given autoload."""
+        result: list[str] = []
+        for path, gd in self._gd_scripts.items():
+            for ref in gd.autoload_refs:
+                if ref.autoload_name == autoload_name:
+                    result.append(path)
+                    break
+        return result
+
+    def to_res_path(self, path: str) -> str:
+        """Convert a filesystem path to a res:// path."""
+        if path.startswith("res://"):
+            return path
+        return f"res://{path}"
+
+    def resolve_file_path(self, path: str) -> str | None:
+        """Resolve a file identifier to a res:// path if it exists in the index."""
+        res_path = self.to_res_path(path)
+        if self._index is None:
+            return None
+        if res_path in self._gd_scripts:
+            return res_path
+        if res_path in self._index.scenes:
+            return res_path
+        return None
+
+
+def discover_project_root() -> Path | None:
+    """Discover the Godot project root directory.
+
+    Strategy:
+    1. Check GODOTIQ_PROJECT_ROOT env var
+    2. Check cwd for .godotiq.json
+    3. Walk up from cwd looking for project.godot
+    """
+    env_root = os.environ.get("GODOTIQ_PROJECT_ROOT")
+    if env_root:
+        p = Path(env_root)
+        if (p / "project.godot").exists():
+            return p
+
+    cwd = Path.cwd()
+    if (cwd / ".godotiq.json").exists():
+        return cwd
+
+    current = cwd
+    while current != current.parent:
+        if (current / "project.godot").exists():
+            return current
+        current = current.parent
+
+    return None
diff --git a/tests/test_tools/test_session.py b/tests/test_tools/test_session.py
new file mode 100644
index 0000000..1b0e713
--- /dev/null
+++ b/tests/test_tools/test_session.py
@@ -0,0 +1,132 @@
+"""Tests for the GodotIQ session layer."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+
+from godotiq.session import GodotIQSession, discover_project_root
+from godotiq.parsers.gd_parser import GdScript
+from godotiq.parsers.tscn_parser import TscnScene
+
+
+@pytest.fixture
+def sample_root() -> Path:
+    return Path(__file__).resolve().parent.parent / "fixtures" / "sample_project"
+
+
+@pytest.fixture
+def session(sample_root: Path) -> GodotIQSession:
+    s = GodotIQSession(sample_root)
+    s.load()
+    return s
+
+
+class TestSessionLoadsProject:
+    def test_session_loads_project(self, session: GodotIQSession) -> None:
+        """Session.load() populates the project index."""
+        assert session._index is not None
+        assert session._index.project_name == "Sample Project"
+        assert session._index.total_scenes >= 2
+        assert session._index.total_scripts >= 4
+
+    def test_session_finds_autoloads(self, session: GodotIQSession) -> None:
+        """Session discovers all autoloads from project.godot."""
+        assert session._index is not None
+        assert "GameManager" in session._index.autoloads
+        assert "EconomyManager" in session._index.autoloads
+        assert "ProgressionManager" in session._index.autoloads
+
+    def test_session_parses_gd_with_autoloads(self, session: GodotIQSession) -> None:
+        """Session parses all .gd files with known autoload names."""
+        assert len(session._gd_scripts) >= 4
+        for path, gd in session._gd_scripts.items():
+            assert isinstance(gd, GdScript)
+
+    def test_session_autoload_pattern1(self, session: GodotIQSession) -> None:
+        """Pattern 1 autoload detection works when session provides known_autoloads."""
+        prog_path = "res://scripts/autoloads/progression_manager.gd"
+        gd = session.get_script(prog_path)
+        assert gd is not None
+        autoload_names = [ref.autoload_name for ref in gd.autoload_refs]
+        assert "GameManager" in autoload_names
+        assert "EconomyManager" in autoload_names
+
+
+class TestSessionQueries:
+    def test_session_get_script(self, session: GodotIQSession) -> None:
+        """get_script() returns parsed GdScript for valid res:// path."""
+        gd = session.get_script("res://scripts/autoloads/economy_manager.gd")
+        assert gd is not None
+        assert gd.class_name == "EconomyManagerClass"
+
+    def test_session_get_script_missing(self, session: GodotIQSession) -> None:
+        """get_script() returns None for unknown path."""
+        assert session.get_script("res://nonexistent.gd") is None
+
+    def test_session_caches_scenes(self, session: GodotIQSession) -> None:
+        """get_scene() returns TscnScene and caches on second call."""
+        scene = session.get_scene("res://scenes/main.tscn")
+        assert scene is not None
+        assert isinstance(scene, TscnScene)
+        scene2 = session.get_scene("res://scenes/main.tscn")
+        assert scene2 is scene
+
+    def test_signal_definitions(self, session: GodotIQSession) -> None:
+        """get_all_signal_definitions() maps signal names to defining scripts."""
+        defs = session.get_all_signal_definitions()
+        assert "cash_changed" in defs
+        assert any("economy_manager" in p for p in defs["cash_changed"])
+
+    def test_signal_emissions(self, session: GodotIQSession) -> None:
+        """get_all_signal_emissions() maps signal names to emitting scripts."""
+        emissions = session.get_all_signal_emissions()
+        assert "cash_changed" in emissions
+
+    def test_signal_connections(self, session: GodotIQSession) -> None:
+        """get_all_signal_connections() maps signal source to connecting scripts."""
+        connections = session.get_all_signal_connections()
+        assert isinstance(connections, dict)
+
+    def test_autoload_dependents(self, session: GodotIQSession) -> None:
+        """get_autoload_dependents() returns scripts that reference an autoload."""
+        deps = session.get_autoload_dependents("GameManager")
+        assert len(deps) >= 1
+
+    def test_to_res_path(self, session: GodotIQSession) -> None:
+        """to_res_path() converts filesystem paths to res:// paths."""
+        res = session.to_res_path("scripts/autoloads/economy_manager.gd")
+        assert res == "res://scripts/autoloads/economy_manager.gd"
+        # Already res:// should pass through
+        assert session.to_res_path("res://foo.gd") == "res://foo.gd"
+
+    def test_resolve_file_path(self, session: GodotIQSession) -> None:
+        """resolve_file_path() finds files by res:// or relative path."""
+        res = session.resolve_file_path("res://scripts/autoloads/economy_manager.gd")
+        assert res == "res://scripts/autoloads/economy_manager.gd"
+        assert session.resolve_file_path("res://nonexistent.gd") is None
+
+
+class TestProjectRootDiscovery:
+    def test_discover_from_env(self, sample_root: Path, monkeypatch) -> None:
+        """Discovers project root from GODOTIQ_PROJECT_ROOT env var."""
+        monkeypatch.setenv("GODOTIQ_PROJECT_ROOT", str(sample_root))
+        result = discover_project_root()
+        assert result is not None
+        assert result == sample_root
+
+    def test_discover_returns_none_when_not_found(self, tmp_path: Path, monkeypatch) -> None:
+        """Returns None when no project root can be found."""
+        monkeypatch.delenv("GODOTIQ_PROJECT_ROOT", raising=False)
+        monkeypatch.chdir(tmp_path)
+        result = discover_project_root()
+        assert result is None
+
+    def test_discover_from_walk_up(self, sample_root: Path, monkeypatch) -> None:
+        """Discovers project root by walking up from a subdirectory."""
+        monkeypatch.delenv("GODOTIQ_PROJECT_ROOT", raising=False)
+        monkeypatch.chdir(sample_root / "scripts" / "entities")
+        result = discover_project_root()
+        assert result is not None
+        assert result == sample_root
