diff --git a/src/godotiq/tools/output_limit.py b/src/godotiq/tools/output_limit.py
new file mode 100644
index 0000000..15d4599
--- /dev/null
+++ b/src/godotiq/tools/output_limit.py
@@ -0,0 +1,149 @@
+"""Output size limiting for MCP tool responses.
+
+Enforces character limits on tool output to prevent context window overflow.
+All tools returning variable-size data should call apply_output_limit() before
+returning their result dict.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+
+logger = logging.getLogger(__name__)
+
+# Character limits per detail level
+_CHAR_LIMITS: dict[str, int | None] = {
+    "brief": 2000,
+    "normal": 8000,
+    "full": None,  # No limit
+}
+
+_VALID_DETAIL_VALUES = {"brief", "normal", "full"}
+
+_TRUNCATION_MSG = "Output truncated. Use detail='full' to see everything."
+
+
+def _normalize_detail(detail: str) -> str:
+    """Normalize detail value, defaulting unknown values to 'normal'."""
+    if detail not in _VALID_DETAIL_VALUES:
+        logger.warning("Unknown detail value %r, normalizing to 'normal'", detail)
+        return "normal"
+    return detail
+
+
+def _estimate_size(result: dict) -> int:
+    """Estimate JSON-serialized character count."""
+    return len(json.dumps(result, default=str, ensure_ascii=False))
+
+
+def apply_output_limit(
+    result: dict,
+    detail: str,
+    list_keys: list[str] | None = None,
+) -> dict:
+    """Enforce character limits on tool output.
+
+    Progressively truncates lists in the result dict until output
+    fits within the character limit for the given detail level.
+
+    Args:
+        result: The tool result dict.
+        detail: "brief", "normal", or "full".
+        list_keys: Keys in result that contain truncatable lists.
+            If None, auto-detects all list-valued keys.
+
+    Returns:
+        The (possibly truncated) result dict.
+    """
+    detail = _normalize_detail(detail)
+    limit = _CHAR_LIMITS.get(detail)
+    if limit is None:
+        return result
+
+    size = _estimate_size(result)
+    if size <= limit:
+        return result
+
+    # Auto-detect list keys if not provided
+    if list_keys is None:
+        list_keys = [k for k, v in result.items() if isinstance(v, list) and len(v) > 1]
+
+    # Sort by list length descending — truncate largest lists first
+    list_keys = sorted(list_keys, key=lambda k: len(result.get(k, [])), reverse=True)
+
+    # Reserve space for truncation metadata (~200 chars)
+    _METADATA_OVERHEAD = 200
+    effective_limit = limit - _METADATA_OVERHEAD
+
+    for key in list_keys:
+        if key not in result or not isinstance(result[key], list):
+            continue
+
+        original_len = len(result[key])
+        if original_len <= 1:
+            continue
+
+        # Binary search for the right truncation point
+        lo, hi = 0, original_len
+        original_list = result[key]
+
+        while lo < hi:
+            mid = (lo + hi + 1) // 2
+            result[key] = original_list[:mid]
+            if _estimate_size(result) <= effective_limit:
+                lo = mid
+            else:
+                hi = mid - 1
+
+        result[key] = original_list[:lo] if lo > 0 else original_list[:1]
+        kept = len(result[key])
+
+        if kept < original_len:
+            omitted = original_len - kept
+            result[f"{key}_total"] = original_len
+            result["output_truncated"] = True
+            result["truncation_notice"] = (
+                f"{_TRUNCATION_MSG} {omitted} item(s) omitted from '{key}'."
+            )
+
+        size = _estimate_size(result)
+        if size <= limit:
+            break
+
+    return result
+
+
+def truncate_output(text: str, detail: str, tool_name: str = "") -> str:
+    """Truncate raw text output to fit detail-level character limits.
+
+    Safety net for string-based output. Uses the same character limits as
+    apply_output_limit() (brief=2000, normal=8000, full=unlimited).
+
+    Args:
+        text: The raw text to potentially truncate.
+        detail: "brief", "normal", or "full".
+        tool_name: Optional tool name for the truncation notice.
+
+    Returns:
+        The (possibly truncated) text with a notice appended if truncated.
+    """
+    if not text:
+        return text
+
+    detail = _normalize_detail(detail)
+    limit = _CHAR_LIMITS.get(detail)
+    if limit is None:
+        return text
+
+    if len(text) <= limit:
+        return text
+
+    # Reserve 200 chars for the truncation notice
+    cut_point = limit - 200
+    truncated = text[:cut_point]
+    remaining = text[cut_point:]
+    remaining_lines = remaining.count("\n") + 1
+
+    notice = f"\n\n... truncated ({remaining_lines} more lines). Use detail='full' for complete output."
+    return truncated + notice
diff --git a/tests/test_tools/test_truncate_output.py b/tests/test_tools/test_truncate_output.py
new file mode 100644
index 0000000..fb70c82
--- /dev/null
+++ b/tests/test_tools/test_truncate_output.py
@@ -0,0 +1,139 @@
+"""Tests for truncate_output() and apply_output_limit() input validation."""
+
+from __future__ import annotations
+
+import json
+import re
+
+from godotiq.tools.output_limit import apply_output_limit, truncate_output
+
+
+class TestTruncateOutput:
+    """Tests for the truncate_output() function."""
+
+    def test_short_text_brief_unchanged(self) -> None:
+        """Text under brief limit (2000 chars) returns unchanged."""
+        text = "hello world" * 10  # 110 chars
+        assert truncate_output(text, "brief") == text
+
+    def test_long_text_brief_truncated(self) -> None:
+        """Text over brief limit is truncated with notice appended."""
+        text = "x" * 100 + "\n" * 50 + "y" * 5000  # well over 2000
+        result = truncate_output(text, "brief")
+        assert len(result) <= 2000
+        assert "truncated" in result
+
+    def test_short_text_normal_unchanged(self) -> None:
+        """Text under normal limit (8000 chars) returns unchanged."""
+        text = "hello world " * 400  # ~4800 chars
+        assert truncate_output(text, "normal") == text
+
+    def test_long_text_normal_truncated(self) -> None:
+        """Text over normal limit is truncated with notice appended."""
+        text = ("a" * 99 + "\n") * 200  # 20000 chars
+        result = truncate_output(text, "normal")
+        assert len(result) <= 8000
+        assert "truncated" in result
+
+    def test_full_never_truncated(self) -> None:
+        """Text with detail='full' is never truncated regardless of size."""
+        text = "x" * 50000
+        assert truncate_output(text, "full") == text
+
+    def test_truncation_notice_includes_line_count(self) -> None:
+        """Truncation notice includes accurate count of remaining lines."""
+        # 100 lines of 101 chars each (100 + newline) = 10100 chars total
+        lines = [("a" * 100) for _ in range(100)]
+        text = "\n".join(lines)
+        result = truncate_output(text, "brief")  # limit 2000
+        # Extract the line count from the notice
+        match = re.search(r"(\d+) more line", result)
+        assert match is not None, f"No line count found in: {result[-200:]}"
+        remaining_lines = int(match.group(1))
+        # The cut point is at 1800 chars (2000 - 200 overhead)
+        # Each line is 101 chars (100 + newline), so ~17 lines fit
+        # Remaining should be ~83 lines
+        assert remaining_lines > 50  # at least more than half
+
+    def test_truncated_text_fits_within_limit(self) -> None:
+        """The truncated text plus notice does not exceed the character limit."""
+        text = ("z" * 99 + "\n") * 200  # 20000 chars
+        for detail in ("brief", "normal"):
+            result = truncate_output(text, detail)
+            limits = {"brief": 2000, "normal": 8000}
+            assert len(result) <= limits[detail], (
+                f"detail={detail}: len={len(result)} > {limits[detail]}"
+            )
+
+    def test_unknown_detail_treated_as_normal(self) -> None:
+        """Unknown detail value (e.g., 'verbose') is treated as 'normal'."""
+        short_text = "x" * 5000  # under normal limit (8000)
+        assert truncate_output(short_text, "verbose") == short_text
+
+        long_text = "x" * 15000  # over normal limit
+        result = truncate_output(long_text, "verbose")
+        assert len(result) <= 8000
+
+    def test_empty_string_unchanged(self) -> None:
+        """Empty string input returns empty string unchanged."""
+        assert truncate_output("", "brief") == ""
+        assert truncate_output("", "normal") == ""
+        assert truncate_output("", "full") == ""
+
+    def test_text_exactly_at_boundary_unchanged(self) -> None:
+        """Text exactly at the limit boundary returns unchanged."""
+        text = "x" * 2000
+        assert truncate_output(text, "brief") == text
+
+
+class TestApplyOutputLimitValidation:
+    """Tests for apply_output_limit() input validation of detail parameter."""
+
+    def _make_big_result(self, count: int = 200) -> dict:
+        """Build a result dict that exceeds 8000 chars."""
+        return {
+            "items": [{"path": f"res://models/subdirectory/model_{i:04d}.glb", "size": i * 100} for i in range(count)]
+        }
+
+    def test_unknown_detail_normalized_to_normal(self) -> None:
+        """Unknown detail value applies normal limits (not unlimited)."""
+        result = self._make_big_result(200)
+        original_size = len(json.dumps(result, default=str))
+        assert original_size > 8000  # confirm it's big enough
+
+        output = apply_output_limit(dict(result), "unknown_value", list_keys=["items"])
+        assert output.get("output_truncated") is True
+
+    def test_verbose_applies_normal_limits(self) -> None:
+        """detail='verbose' applies normal limits, not unlimited."""
+        result = self._make_big_result(200)
+        output = apply_output_limit(dict(result), "verbose", list_keys=["items"])
+        output_size = len(json.dumps(output, default=str))
+        assert output_size <= 8000
+
+    def test_empty_string_applies_normal_limits(self) -> None:
+        """detail='' (empty string) applies normal limits, not unlimited."""
+        result = self._make_big_result(200)
+        output = apply_output_limit(dict(result), "", list_keys=["items"])
+        assert output.get("output_truncated") is True
+
+    def test_valid_brief_unchanged(self) -> None:
+        """detail='brief' continues to work correctly."""
+        result = {"items": [{"x": "y"}]}
+        output = apply_output_limit(result, "brief")
+        assert output == result  # small result unchanged
+
+    def test_valid_normal_unchanged(self) -> None:
+        """detail='normal' continues to work correctly."""
+        result = {"items": [{"x": "y"}]}
+        output = apply_output_limit(result, "normal")
+        assert output == result
+
+    def test_valid_full_unchanged(self) -> None:
+        """detail='full' continues to work correctly (no limit)."""
+        result = self._make_big_result(200)
+        items_count = len(result["items"])
+        output = apply_output_limit(dict(result), "full", list_keys=["items"])
+        # Full should never truncate
+        assert len(output["items"]) == items_count
+        assert output.get("output_truncated") is not True
