diff --git a/src/godotiq/tools/animation/__init__.py b/src/godotiq/tools/animation/__init__.py
index e0ef5b3..db8252e 100644
--- a/src/godotiq/tools/animation/__init__.py
+++ b/src/godotiq/tools/animation/__init__.py
@@ -706,3 +706,287 @@ async def godotiq_animation_info(
     if session is None:
         return {"error": "No Godot project loaded"}
     return _build_animation_info(session, scene, node, detail)
+
+
+# ---------------------------------------------------------------------------
+# 4.0  _build_animation_audit
+# ---------------------------------------------------------------------------
+
+_SEVERITY_ORDER = {"critical": 0, "warning": 1, "info": 2}
+_MAX_ISSUES = 50
+_LOOP_SUSPECT_KEYWORDS = {"idle", "walk", "run", "swim", "fly", "breathe"}
+_LOOP_SUSPECT_MAX_LENGTH = 3.0
+
+
+def _build_animation_audit(
+    session: GodotIQSession,
+    scope: str = "all",
+    scene_filter: str | None = None,
+) -> dict:
+    """Find animation problems across the project or in a specific scene.
+
+    Args:
+        session: A loaded GodotIQSession.
+        scope: "all" to scan all scenes, "scene" for a single scene.
+        scene_filter: res:// path when scope="scene".
+
+    Returns:
+        Dict with scope, scenes_scanned, animated_scenes, total_animations,
+        total_issues, by_severity, issues.
+    """
+    if scope == "scene" and scene_filter:
+        scene_paths = [scene_filter]
+    elif session._index:
+        scene_paths = list(session._index.scenes.keys())
+    else:
+        return {"error": "No project index available"}
+
+    issues: list[dict] = []
+    scenes_scanned = 0
+    animated_scenes = 0
+    total_animations = 0
+
+    for scene_path in scene_paths:
+        scene = session.get_scene(scene_path)
+        if scene is None:
+            continue
+        scenes_scanned += 1
+
+        summary = _build_animation_summary(scene_path, scene, session)
+        players = summary["players"]
+        animations = summary["animations"]
+        libraries = summary["libraries"]
+        trees = summary["trees"]
+        code_anims = summary["code_animations"]
+
+        if not players and not trees:
+            continue
+        animated_scenes += 1
+        total_animations += len(animations)
+
+        # Build lookup structures
+        anim_lookup: dict[str, dict] = {a["sub_resource_id"]: a for a in animations}
+        scene_anim_names: set[str] = {a["name"] for a in animations}
+
+        # Collect all code references
+        all_play_calls: set[str] = set()
+        all_anim_map_values: set[str] = set()
+        all_anim_map_entries: list[dict] = []
+        for ca in code_anims:
+            all_play_calls.update(ca.get("play_calls", []))
+            for am in ca.get("anim_maps", []):
+                all_anim_map_values.update(am["entries"].values())
+                all_anim_map_entries.append(am)
+
+        # Collect tree animation refs
+        tree_anim_refs: set[str] = set()
+        for tree in trees:
+            tree_anim_refs.update(tree["animation_refs"])
+
+        # All referenced names (for orphan check)
+        all_referenced: set[str] = set()
+        all_referenced.update(all_play_calls)
+        all_referenced.update(all_anim_map_values)
+        all_referenced.update(tree_anim_refs)
+        for ca in code_anims:
+            for ll in ca.get("looping_lists", []):
+                all_referenced.update(ll["entries"])
+
+        # Collect node names for broken track check
+        node_names: set[str] = {n.name for n in scene.nodes}
+
+        # --- Check 1: broken_track (CRITICAL) ---
+        for anim in animations:
+            for track_path in anim.get("track_paths", []):
+                # Self-reference is always valid
+                if track_path == "." or track_path.startswith(".:"):
+                    continue
+                # Extract first node segment
+                node_part = track_path.split(":")[0]
+                first_segment = node_part.split("/")[0]
+                if first_segment and first_segment not in node_names:
+                    issues.append({
+                        "type": "broken_track",
+                        "severity": "critical",
+                        "scene": scene_path,
+                        "animation": anim["name"],
+                        "track_path": track_path,
+                        "missing_node": first_segment,
+                        "message": f"Track targets non-existent node '{first_segment}'",
+                    })
+
+        # --- Check 2: orphan_animation (WARNING) ---
+        has_references = bool(code_anims) or bool(trees)
+        if has_references:
+            for anim in animations:
+                if anim["name"] not in all_referenced:
+                    issues.append({
+                        "type": "orphan_animation",
+                        "severity": "warning",
+                        "scene": scene_path,
+                        "animation": anim["name"],
+                        "message": f"Animation '{anim['name']}' is never referenced in code or AnimationTree",
+                    })
+
+        # --- Check 3: missing_animation (WARNING / INFO for GLB) ---
+        # Detect GLB-sourced names via ANIM_MAP with NlaTrack-like values
+        glb_names: set[str] = set()
+        for am in all_anim_map_entries:
+            for _key, val in am["entries"].items():
+                if "nlatrack" in val.lower() or "nla" in val.lower():
+                    glb_names.update(am["entries"].values())
+                    break
+
+        for name in all_play_calls:
+            if name not in scene_anim_names:
+                if name in glb_names:
+                    severity = "info"
+                else:
+                    severity = "warning"
+                issues.append({
+                    "type": "missing_animation",
+                    "severity": severity,
+                    "scene": scene_path,
+                    "animation": name,
+                    "message": f"Code calls .play('{name}') but animation not found in scene",
+                })
+
+        # --- Check 4: no_loop_suspect (INFO) ---
+        for anim in animations:
+            if anim["loop_mode"] != "none":
+                continue
+            if anim["length"] >= _LOOP_SUSPECT_MAX_LENGTH:
+                continue
+            name_lower = anim["name"].lower()
+            if any(kw in name_lower for kw in _LOOP_SUSPECT_KEYWORDS):
+                issues.append({
+                    "type": "no_loop_suspect",
+                    "severity": "info",
+                    "scene": scene_path,
+                    "animation": anim["name"],
+                    "length": anim["length"],
+                    "message": f"Animation '{anim['name']}' ({anim['length']}s) looks like it should loop",
+                })
+
+        # --- Check 5: empty_player (WARNING) ---
+        for player in players:
+            has_animations = False
+            for _lib_name, lib_sub_id in player["library_refs"].items():
+                lib_map = libraries.get(lib_sub_id, {})
+                if lib_map:
+                    has_animations = True
+                    break
+            if not has_animations:
+                issues.append({
+                    "type": "empty_player",
+                    "severity": "warning",
+                    "scene": scene_path,
+                    "player": player["name"],
+                    "message": f"AnimationPlayer '{player['name']}' has no animations",
+                })
+
+        # --- Check 6: code_only_animations (INFO) ---
+        for name in summary["code_only"]:
+            issues.append({
+                "type": "code_only_animations",
+                "severity": "info",
+                "scene": scene_path,
+                "animation": name,
+                "message": f"Animation '{name}' only referenced in code, can't validate from filesystem",
+            })
+
+        # --- Check 7: orphan_tree_state (WARNING) ---
+        for tree in trees:
+            # Find the linked AnimationPlayer's animations
+            player_anims: set[str] = set()
+            for player in players:
+                player_anims.update(
+                    anim_lookup[anim_sub_id]["name"]
+                    for _lib_name, lib_sub_id in player["library_refs"].items()
+                    for _anim_name, anim_sub_id in libraries.get(lib_sub_id, {}).items()
+                    if anim_sub_id in anim_lookup
+                )
+
+            for ref in tree["animation_refs"]:
+                if ref not in player_anims:
+                    issues.append({
+                        "type": "orphan_tree_state",
+                        "severity": "warning",
+                        "scene": scene_path,
+                        "tree": tree["node_name"],
+                        "animation": ref,
+                        "message": f"AnimationTree state references '{ref}' not found in AnimationPlayer",
+                    })
+
+    # Sort by severity, then scene path
+    issues.sort(key=lambda i: (_SEVERITY_ORDER.get(i["severity"], 9), i.get("scene", "")))
+
+    # Cap at MAX_ISSUES, preserving critical first
+    if len(issues) > _MAX_ISSUES:
+        # Keep all critical, then fill with warning, then info
+        critical = [i for i in issues if i["severity"] == "critical"]
+        warning = [i for i in issues if i["severity"] == "warning"]
+        info = [i for i in issues if i["severity"] == "info"]
+
+        kept: list[dict] = []
+        for group in [critical, warning, info]:
+            remaining = _MAX_ISSUES - len(kept)
+            if remaining <= 0:
+                break
+            kept.extend(group[:remaining])
+
+        issues = kept
+        truncated = True
+    else:
+        truncated = False
+
+    by_severity: dict[str, int] = {"critical": 0, "warning": 0, "info": 0}
+    for issue in issues:
+        sev = issue["severity"]
+        if sev in by_severity:
+            by_severity[sev] += 1
+
+    result: dict = {
+        "scope": scope,
+        "scenes_scanned": scenes_scanned,
+        "animated_scenes": animated_scenes,
+        "total_animations": total_animations,
+        "total_issues": len(issues),
+        "by_severity": by_severity,
+        "issues": issues,
+    }
+    if truncated:
+        result["truncated"] = True
+
+    return result
+
+
+# ---------------------------------------------------------------------------
+# MCP Tool: godotiq_animation_audit
+# ---------------------------------------------------------------------------
+
+
+@mcp.tool(
+    name="godotiq_animation_audit",
+    annotations={
+        "readOnlyHint": True,
+        "destructiveHint": False,
+        "idempotentHint": True,
+        "openWorldHint": False,
+    },
+)
+async def godotiq_animation_audit(
+    scope: str = "all",
+    scene: str | None = None,
+    ctx: Context = None,
+) -> dict:
+    """Find animation problems across the project or in a specific scene.
+
+    Args:
+        scope: "all" to scan all scenes, "scene" for a single scene.
+        scene: Path to .tscn file when scope="scene" (res:// or relative).
+    """
+    session = ctx.request_context.lifespan_context["session"]
+    if session is None:
+        return {"error": "No Godot project loaded"}
+    return _build_animation_audit(session, scope, scene)
