CoplayDev/unity-mcp · error · RuntimeError

replace_range out of bounds

Error message

replace_range out of bounds

What it means

In script_apply_edits, the replace_range operation validates 1-based line/column coordinates before splicing. The bounds check rejects: start_line < 1, end_line < start_line, end_line > len(lines)+1 (exclusive end allows appending), or any column < 1. If any condition fails, RuntimeError fires. The coordinates refer to the file text BEFORE this edit is applied (edits apply sequentially to the evolving text buffer).

Source

Thrown at Server/src/services/tools/script_apply_edits.py:434

            match = _find_best_anchor_match(
                anchor, text, flags, bool(edit.get("prefer_last", True)))
            if not match:
                if edit.get("allow_noop", True):
                    continue
                raise RuntimeError(f"anchor not found: {anchor}")
            idx = match.start() if position == "before" else match.end()
            text = text[:idx] + insert_text + text[idx:]
        elif op == "replace_range":
            start_line = int(edit.get("startLine", 1))
            start_col = int(edit.get("startCol", 1))
            end_line = int(edit.get("endLine", start_line))
            end_col = int(edit.get("endCol", 1))
            replacement = edit.get("text", "")
            lines = text.splitlines(keepends=True)
            max_line = len(lines) + 1  # 1-based, exclusive end
            if (start_line < 1 or end_line < start_line or end_line > max_line
                    or start_col < 1 or end_col < 1):
                raise RuntimeError("replace_range out of bounds")

            def index_of(line: int, col: int) -> int:
                if line <= len(lines):
                    return sum(len(l) for l in lines[: line - 1]) + (col - 1)
                return sum(len(l) for l in lines)
            a = index_of(start_line, start_col)
            b = index_of(end_line, end_col)
            text = text[:a] + replacement + text[b:]
        elif op == "regex_replace":
            pattern = edit.get("pattern", "")
            repl = edit.get("replacement", "")
            # Translate $n backrefs (our input) to Python \g<n>
            repl_py = re.sub(r"\$(\d+)", r"\\g<\1>", repl)
            count = int(edit.get("count", 0))  # 0 = replace all
            flags = re.MULTILINE
            if edit.get("ignore_case"):
                flags |= re.IGNORECASE
            text = re.sub(pattern, repl_py, text, count=count, flags=flags)

View on GitHub (pinned to c21bf496bc)

Solutions

  1. Re-read the file and recompute line/col coordinates against the current content.
  2. Remember coordinates are 1-based: the first line is 1, the first column is 1 (not 0).
  3. endLine is exclusive and may be len(lines)+1 to append; do not exceed that.
  4. If stacking multiple edits, apply them in an order that does not invalidate later coordinates, or recompute after each.

Example fix

// before (0-based, stale)
edits=[{"op": "replace_range", "startLine": 0, "startCol": 0, "endLine": 5, "endCol": 0, "text": "// replaced\n"}]
// after (1-based, validated against current file)
edits=[{"op": "replace_range", "startLine": 1, "startCol": 1, "endLine": 6, "endCol": 1, "text": "// replaced\n"}]
Defensive patterns

Strategy: validation

Validate before calling

def validate_replace_range(edit: dict, text: str) -> None:
    lines = text.splitlines(keepends=True)
    max_line = len(lines) + 1
    start_line = int(edit.get("startLine", 1))
    start_col = int(edit.get("startCol", 1))
    end_line = int(edit.get("endLine", start_line))
    end_col = int(edit.get("endCol", 1))
    if (start_line < 1 or end_line < start_line or end_line > max_line
            or start_col < 1 or end_col < 1):
        raise ValueError(f"replace_range out of bounds: file has {len(lines)} lines, "
                         f"requested start={start_line}:{start_col} end={end_line}:{end_col}")

# Call with the CURRENT text before script_apply_edits:
for edit in edits:
    if edit.get("op") == "replace_range":
        validate_replace_range(edit, current_text)

Type guard

def is_in_bounds_replace_range(edit: dict, text: str) -> bool:
    lines = text.splitlines(keepends=True)
    max_line = len(lines) + 1
    sl = int(edit.get("startLine", 1)); sc = int(edit.get("startCol", 1))
    el = int(edit.get("endLine", sl)); ec = int(edit.get("endCol", 1))
    return not (sl < 1 or el < sl or el > max_line or sc < 1 or ec < 1)

Prevention

When it happens

Trigger: An edit specifies endLine beyond the file length; startCol or endCol is 0 (columns are 1-based); endLine < startLine (inverted range); the file was shortened by a prior edit in the same batch, making previously-valid coordinates stale; a caller uses 0-based line numbers.

Common situations: An AI computed line numbers from a stale file read; a prior edit in the same batch deleted lines, shifting indices; the caller uses 0-based indexing (this tool is 1-based); an edit targets the end of file but endLine exceeds len(lines)+1.

Related errors


AI-assisted analysis of CoplayDev/unity-mcp@c21bf496bc (2026-08-13). Data as JSON: /api/errors/b7c27ab91f15e887. Report an issue: GitHub.