Skip to content

About

Zero-dependency MCP server giving AI agents ten file-editing tools. Address an edit by text anchor or by line number — both self-validating — and every edit returns a unified diff. Stateless, atomic writes, never half-applied. Python 3.9+, stdlib only, Windows and Linux.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

text-edit — MCP file editing tools

A zero-dependency MCP stdio server giving an AI agent ten file tools. It offers two ways to address an edit — by text anchor when you know the text, by line number when the block is large — and every edit returns a unified diff, so you never need a second call to see what happened.

Runs identically on Windows and Linux. Python 3.9+. Standard library only, nothing to install.

view_lines     path start end                                             → numbered window
find_text      path text                                                  → how many, and where

replace_text   path old_text new_text expect_count                        → character-exact
replace_text_batch  path edits[]                                          → many, all-or-nothing
insert_text    path anchor where new_text                                 → lines by anchor
append_text    path new_text                                              → lines at the end

replace_lines  path start end new_text expect_start_line expect_end_line  → block by number
insert_lines   path before new_text expect_line                           → lines by number
delete_lines   path start end expect_start_line expect_end_line           → remove by number

Table of contents


Every edit returns a diff

All seven writing tools answer with the same three things: what happened, the file's shape afterwards, and a git-style unified diff. That is one round trip instead of two — no follow-up read, no git diff, no guessing.

replaced 2 occurrence(s) in /home/me/project/app.py
lines 16 → 16 · newline lf · encoding utf-8
@@ -3,7 +3,7 @@
 
 
 def connect(host):
-    timeout = 5
+    timeout = 30
     retries = 3
     if not host:
         raise ValueError("no host")
@@ -11,6 +11,6 @@
 
 
 def connect_again(host):
-    timeout = 5
+    timeout = 30
     return open_socket(host, timeout, 1)

The shape line is not decoration: a write rewrites the whole file, so it states the line count before and after, the line ending used, the encoding, and · bom when the file has one.

This is structural, not a convention — every writing handler funnels through one function that always renders the diff, and a test asserts it per tool.


Two ways to address an edit

There are two ways to say where, and they cost and fail differently. Both are available, and picking between them is the only real decision this toolset asks of you.

Text anchor (replace_text) Line numbers (replace_lines)
What you send the whole old text two numbers + the two boundary lines
Cost to change 30 lines 30 lines of old text 2 lines
Address can go stale no — a changed file simply does not match yes — but the boundary assertions catch it
Strength asserts every character of the block asserts the first and last line
Use it when the target is small, or you want maximum certainty the block is big

Why anchors need no interlock. The address is the content, so it is self-validating: if the file changed, the anchor no longer matches and the edit is refused. That is why this server keeps no session state, no viewed-window queue, no version token, and no cache.

Why line numbers are safe here anyway. A line number goes stale the moment anything above it changes — which is why line-based editors normally need an interlock. The three line tools get the same guarantee for a constant cost: you assert what the boundary lines currently say (both ends for replace_lines / delete_lines, the one line for insert_lines), and a mismatch refuses the edit and writes nothing.

And line numbers reach one thing anchors cannot. An anchor must match exactly once, so inside a run of identical lines no anchor names one of them — insert_lines exists for that case, and its assertion deliberately does not require uniqueness. See insert_lines.

ERROR: line 8 does not say what 'expect_start_line' asserts, so these line numbers are stale — nothing was changed.
  asserted: '    if not hosts:'
  actually: '    if not host:'
Retry: {"tool": "view_lines", "arguments": {"path": "app.py", "start": 1, "end": 19}}

⚠️ The residual risk, stated plainly: two lines are not the whole block. A change that left both boundary lines intact while altering the middle would pass. When that matters, use replace_text — asserting the entire old text is strictly stronger, and strictly more expensive.

⚠️ Do not point a weak model at this server. Every addressing mode here requires reproducing some text exactly. A model that paraphrases will keep hitting "not found" or "stale" — safe, but it will never successfully edit anything. For that case use a pure line-number editor with a viewed-window interlock, such as hermes-agent's line_editor.

The full reasoning, including every rejected alternative, is in docs/design/text-edit-tools.md.


Install

Clone or copy the repository anywhere, then register mcp_server.py with your MCP client.

Claude Code / any client using mcpServers JSON

Linux / macOS

{
  "mcpServers": {
    "text-edit": {
      "command": "python3",
      "args": ["/opt/LineEditor-MCP-Tools/mcp_server.py"],
      "env": {
        "TEXT_EDIT_ROOTS": "/home/me/project:/home/me/notes"
      }
    }
  }
}

Windows

{
  "mcpServers": {
    "text-edit": {
      "command": "python",
      "args": ["D:/Weil/Desktop/AI/LineEditor-MCP-Tools/mcp_server.py"],
      "env": {
        "TEXT_EDIT_ROOTS": "D:/Weil/Desktop/AI/hermes-agent;D:/Weil/Desktop/AI/notes"
      }
    }
  }
}

Note the separator: ; on Windows, : elsewhere (it is os.pathsep). Forward slashes work fine on Windows and avoid escaping backslashes in JSON.

Claude Code CLI

claude mcp add text-edit -e TEXT_EDIT_ROOTS=/home/me/project -- python3 /opt/LineEditor-MCP-Tools/mcp_server.py

Verify it starts

The server speaks JSON-RPC on stdout and logs to stderr, so you can smoke-test it by hand:

echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | TEXT_EDIT_ROOTS=/home/me/project python3 mcp_server.py

One line of JSON listing ten tools, plus text-edit 0.1.0 ready · may touch: … on stderr.


Configuration

Two environment variables. Both are read by the server process, never from a tool argument — a caller that could name its own boundary would not have one.

Variable Default Effect
TEXT_EDIT_ROOTS the server's working directory os.pathsep-separated list of directories these tools may touch. Everything outside is refused, for reads as well as writes
TEXT_EDIT_COLOR on Set to never (or off/false/0) for an uncoloured diff

How TEXT_EDIT_ROOTS behaves

  • One directory or many. TEXT_EDIT_ROOTS=/a and TEXT_EDIT_ROOTS=/a:/b are both valid.
  • Unset → the server's working directory becomes the single allowed root. Convenient, but be aware of where your client launches the server from.
  • Set but one entry is bad (typo, missing directory, a file instead of a directory) → the server refuses to start, exit code 2, naming the offending entry. A list where two of three entries resolved is a list nobody wrote.
  • A filesystem root (/ or C:\) is rejected — it would allow every file on the machine, which is not a boundary. If the fallback working directory is itself a filesystem root, the server refuses to start rather than allowing everything.
  • Relative paths in tool calls resolve against the first root, and a not-found error says which root it resolved against.
  • Symlinks cannot escape. Every path is fully resolved before the containment check, so a link inside a root pointing outside it is refused.

How TEXT_EDIT_COLOR behaves

Every writing tool returns a unified diff, and those diffs are coloured the way git colours its own: added lines green, removed lines red, hunk headers cyan, context lines untouched. Basic SGR codes only, so the client's own theme picks the actual hue.

  • On by default, with no isatty check. The usual auto-detect is worse than useless for a stdio server: its stdout is a pipe every single time, so "not a terminal" would disable colour for the one consumer that does render it — the client displaying the tool result.
  • Whole lines are wrapped, and the escapes sit at the two ends — never inside the content. A line lifted out of a diff is still usable as a replace_text anchor once its + prefix is stripped, which matters here because in this toolset the text is the address.
  • Turn it off for a client that shows escapes literally instead of rendering them, or when something downstream matches on the output as a string.

The ten tools

Every tool takes path (absolute, or relative to the first allowed root) and optional encoding — except outline_files, which takes paths, a list. The seven writing tools also take optional newline.

All output below is real, captured from the tools against this sample file (only the directory has been genericized):

 1  import os
 2  import sys
 3
 4
 5  def connect(host):
 6      timeout = 5
 7      retries = 3
 8      if not host:
 9          raise ValueError("no host")
10      return open_socket(host, timeout, retries)
11
12
13  def connect_again(host):
14      timeout = 5
15      return open_socket(host, timeout, 1)
16

view_lines — read a numbered window

argument type meaning
path string the file
start integer first line, 1-based
end integer last line, inclusive. 0 = to the end of the file
{"path": "app.py", "start": 5, "end": 10}
/home/me/project/app.py — 16 lines · newline lf · encoding utf-8
showing lines 5-10
----
5	def connect(host):
6	    timeout = 5
7	    retries = 3
8	    if not host:
9	        raise ValueError("no host")
10	    return open_socket(host, timeout, retries)

Lines are shown verbatim, indentation included, because you copy them into expect_start_line / expect_end_line where they are compared exactly.

Capped at 2000 lines per call; when it caps, the header says so and names the line to ask for next. Reading licenses nothing — there is no "you must view before you edit" rule here.

find_text — count and locate

argument type meaning
path string the file
text string exact substring (not a regex)
{"path": "app.py", "text": "timeout = 5"}
2 occurrence(s) in /home/me/project/app.py (16 lines · newline lf · encoding utf-8)
found at:
6	    timeout = 5
14	    timeout = 5

Zero occurrences is not an error — "it is not there" is an answer. Use this before a multi-site replace_text so expect_count is a fact rather than a guess.

Rows are <line>\t<that line, verbatim> — the same shape view_lines and outline_files use, so "a numbered line" reads the same way in all three.

outline_files — the map you read before reading anything

argument type meaning
paths array of strings the .py / .md files to map. A lone string is accepted
{"paths": ["app.py", "README.md"]}
outline: /home/me/project/app.py   16 lines · newline lf · encoding utf-8
1	imports (lines 1-2, 2 modules)
5	def connect(host):
13	def connect_again(host):

outline: /home/me/project/README.md   40 lines · newline lf · encoding utf-8
1	# Project
3	What it does, in one line.
8	## Install
10	pip install -e .

What it captures. .py: contiguous runs of top-level imports collapsed to one row, module-level bindings, section-divider comments (# ── reading ──), and class / def / async def three levels deep. .md: ATX headings #..######, thematic breaks, and the first non-empty line under each heading — a heading is often the less informative half.

It is a line scan, not a parser. The file you most need a map of is often the one you are half-way through editing, i.e. syntactically broken — and ast.parse answers a broken file with nothing at all. Three things a naive scan would get wrong are handled: a def inside a docstring is not a definition, a # inside a ``` fence is not a heading, and a triple quote that merely appears inside a short string (PATTERN = r'"""') opens no docstring at all — counting triple quotes per line instead of reading string state got that last one backwards, and one wrong answer inverted the phase for every line below it.

Several files, one call, up to 20; 200 rows per file and 800 per call, and every cap says what it dropped. One unreadable path does not sink the batch — it reports for itself, and the call fails only when nothing could be read. An unsupported extension says so instead of inventing a map.

replace_text — character-exact surgery

argument type meaning
path string the file
old_text string exact text to replace, whitespace included
new_text string replacement. "" deletes exactly those characters
expect_count integer ≥ 1 how many occurrences you assert exist

A mismatch writes nothing, and hands back the corrected call:

{"path": "app.py", "old_text": "timeout = 5", "new_text": "timeout = 30", "expect_count": 1}
ERROR: expected 1 occurrence(s), found 2.
found at:
  6 |     timeout = 5
  14 |     timeout = 5
extend 'old_text' until it is unique, or pass expect_count=2 to change all of them
Retry: {"tool": "replace_text", "arguments": {"path": "app.py", "old_text": "timeout = 5", "new_text": "timeout = 30", "expect_count": 2}}

With the count asserted correctly:

{"path": "app.py", "old_text": "timeout = 5", "new_text": "timeout = 30", "expect_count": 2}
replaced 2 occurrence(s) in /home/me/project/app.py
lines 16 → 16 · newline lf · encoding utf-8
@@ -3,7 +3,7 @@
 
 
 def connect(host):
-    timeout = 5
+    timeout = 30
     retries = 3
     if not host:
         raise ValueError("no host")
@@ -11,6 +11,6 @@
 
 
 def connect_again(host):
-    timeout = 5
+    timeout = 30
     return open_socket(host, timeout, 1)

expect_count is the safety feature, and the reason there is no replace_all: true flag: that flag accepts 43 matches and 44 matches identically, and a rename sweep that hits one extra site is exactly the accident nobody notices until much later.

Deleting a whole line: include its newline in old_text. old_text: "foo" on a line by itself leaves a blank line; old_text: "foo\n" removes the line.

Matching is one left-to-right pass, non-overlapping. Two things follow, and both are pinned by tests:

  • "aabbaabbaabb" with old_text: "bbaa" is 2 occurrences, not 3 — they sit at index 2 and 6, and what remains at 10 is only "bb". The count you assert is always the count applied; that invariant is brute-forced over random inputs.
  • The result is not rescanned, so an occurrence newly formed at a seam survives. Removing "ab" from "aabb" gives "ab" — one replacement, exactly as asserted, and old_text is still in the file. A successful edit promises the count, not "it is gone now". When you mean "and then again", say so with replace_text_batch.

replace_text_batch — several replacements, all-or-nothing

argument type meaning
path string the file
edits array {old_text, new_text, expect_count} objects, applied in order

Each expect_count is checked against the text as it stands when that edit runs, so an edit may depend on an earlier one. The file is written once, and you get one diff:

{"path": "app.py", "edits": [
  {"old_text": "timeout = 5", "new_text": "timeout = 30", "expect_count": 2},
  {"old_text": "def connect(host):", "new_text": "def connect(host: str):", "expect_count": 1}]}
applied 2 edit(s), 3 replacement(s) in total in /home/me/project/app.py
lines 9 → 9 · newline lf · encoding utf-8
@@ -1,9 +1,9 @@
-def connect(host):
-    timeout = 5
+def connect(host: str):
+    timeout = 30
     return open_socket(host, timeout)
 
 
 def connect_again(host):
-    timeout = 5
+    timeout = 30
     return open_socket(host, timeout)

If any one edit is refused, none of them are applied — and the refusal names which:

{"path": "app.py", "edits": [
  {"old_text": "timeout = 5", "new_text": "timeout = 30", "expect_count": 2},
  {"old_text": "open_socket", "new_text": "open_conn", "expect_count": 1}]}
ERROR: edit 2 of 2 was refused, so NOTHING was written (the 1 before it are discarded too):
expected 1 occurrence(s), found 2.
found at:
  3 |     return open_socket(host, timeout)
  8 |     return open_socket(host, timeout)
extend 'old_text' until it is unique, or pass expect_count=2 to change all of them

Edit 1 would have succeeded on its own. That is the property N separate calls cannot give you: no half-edited file to unpick by hand, and no need to reason about which of the fifteen went through. This is the one tool with a nested argument; why that does not break the no-envelope rule is in the design doc §1.6 — the array is the payload, not a wrapper choosing an operation.

insert_text — whole lines, anchored

argument type meaning
path string the file
anchor string exact text identifying one line. Not modified
where "before" | "after" which side of that line
new_text string line(s) to insert. "" = one blank line
{"path": "app.py", "anchor": "def connect(host):", "where": "before", "new_text": "@retry(3)"}
inserted 1 line(s) before the anchor in /home/me/project/app.py
lines 16 → 17 · newline lf · encoding utf-8
@@ -2,6 +2,7 @@
 import sys
 
 
+@retry(3)
 def connect(host):
     timeout = 5
     retries = 3

An ambiguous anchor is refused with every match listed, and the Retry: is a view_lines window around the first one — because the fix is to look, then lengthen the anchor:

{"path": "app.py", "anchor": "    timeout = 5", "where": "after", "new_text": "x"}
ERROR: 'anchor' must match exactly once, found 2.
found at:
  6 |     timeout = 5
  14 |     timeout = 5
extend 'anchor' until exactly one line matches
Retry: {"tool": "view_lines", "arguments": {"path": "app.py", "start": 1, "end": 16}}

A multi-line anchor is fine: before means before its first line, after after its last, so the anchor is never split. For character-level insertion use replace_text with new_text = old_text + your addition.

append_text — whole lines at the end

argument type meaning
path string the file
new_text string line(s) to append. "" = one blank line
{"path": "app.py", "new_text": "\n# end of module"}
appended 2 line(s) in /home/me/project/app.py
lines 16 → 18 · newline lf · encoding utf-8
@@ -14,3 +14,5 @@
     timeout = 5
     return open_socket(host, timeout, 1)
 
+# end of module
+

A file that ended with a newline still does; one that did not, still does not. An empty file becomes "line\n" (the POSIX convention).

replace_lines — a block, by number

argument type meaning
path string the file
start integer first line to replace, 1-based
end integer last line, inclusive
new_text string replacement line(s). "" = one blank line
expect_start_line string the exact current text of line start
expect_end_line string the exact current text of line end
{"path": "app.py", "start": 8, "end": 9,
 "new_text": "    if host is None:\n        return None",
 "expect_start_line": "    if not host:",
 "expect_end_line": "        raise ValueError(\"no host\")"}
replaced with 2 line(s) in /home/me/project/app.py
lines 16 → 16 · newline lf · encoding utf-8
@@ -5,8 +5,8 @@
 def connect(host):
     timeout = 5
     retries = 3
-    if not host:
-        raise ValueError("no host")
+    if host is None:
+        return None
     return open_socket(host, timeout, retries)

Two lines of assertion replaced two lines here — but the same two lines would have covered a thirty-line block, which is the whole point of this tool.

When start == end, pass the same line as both assertions. That is deliberate over making one of them conditional: an argument that is required only sometimes is the shape this toolset avoids.

insert_lines — whole lines, above a line NUMBER

argument type meaning
path string the file
before integer insert above this 1-based line. Must be 1..total
new_text string line(s) to insert. "" = one blank line
expect_line string the exact current text of line before
{"path": "app.py", "before": 3, "new_text": "    log(host)", "expect_line": "    if not host:"}
inserted 1 line(s) before line 3 in /home/me/project/app.py
lines 6 → 7 · newline lf · encoding utf-8
@@ -1,5 +1,6 @@
 def connect(host):
     timeout = 5
+    log(host)
     if not host:
         raise ValueError("no host")
     return open_socket(host, timeout)

Why this exists when insert_text already inserts lines: an anchor cannot address every position. An anchor must match exactly once, so inside a run of identical lines there is no anchor that names one of them:

{"path": "braces.txt", "anchor": "}", "where": "after", "new_text": "X"}
ERROR: 'anchor' must match exactly once, found 5.
found at:
  1 | }
  2 | }
  3 | }
  4 | }
  5 | }
extend 'anchor' until exactly one line matches

A line number names it, and the assertion still guards staleness — note it does not require uniqueness, which is exactly why it reaches the position an anchor cannot:

{"path": "braces.txt", "before": 3, "new_text": "X", "expect_line": "}"}
inserted 1 line(s) before line 3 in /home/me/project/braces.txt
lines 6 → 7 · newline lf · encoding utf-8
@@ -1,5 +1,6 @@
 }
 }
+X
 }
 }
 }

before = total + 1 is refused rather than treated as an append: appending has no line to assert, and the end of a file cannot go stale, so that is append_text's job.

ERROR: 'before' (99) is past the last line (6) — use append_text to add at the end of the file
Retry: {"tool": "view_lines", "arguments": {"path": "app.py", "start": 1, "end": 6}}

delete_lines — remove a block, by number

argument type meaning
path string the file
start integer first line to remove, 1-based
end integer last line, inclusive
expect_start_line string the exact current text of line start
expect_end_line string the exact current text of line end
{"path": "app.py", "start": 13, "end": 15,
 "expect_start_line": "def connect_again(host):",
 "expect_end_line": "    return open_socket(host, timeout, 1)"}
deleted 3 line(s) in /home/me/project/app.py
lines 16 → 13 · newline lf · encoding utf-8
@@ -10,7 +10,4 @@
     return open_socket(host, timeout, retries)
 
 
-def connect_again(host):
-    timeout = 5
-    return open_socket(host, timeout, 1)

Shared optional arguments

argument tools values meaning
encoding all ten auto (default), or any Python codec auto reads the BOM, else UTF-8. Content is never sniffed
newline the seven writers auto (default), lf, crlf, cr what to write. auto keeps the file's own

Recipes

Rename a symbol across a file, asserting the count

find_text     {"path": "app.py", "text": "OldName"}          → 12 occurrence(s)
replace_text  {"path": "app.py", "old_text": "OldName", "new_text": "NewName", "expect_count": 12}

If the count came back 13, you learn it before anything is written.

A coordinated change — the rename plus the flag plus the docstring, or nothing

replace_text_batch {"path": "app.py", "edits": [
  {"old_text": "OldName", "new_text": "NewName", "expect_count": 12},
  {"old_text": "old_flag", "new_text": "new_flag", "expect_count": 1},
  {"old_text": "the OldName helper", "new_text": "the NewName helper", "expect_count": 1}]}

Three separate replace_text calls leave the file half-renamed when the third one's count turns out to be wrong. This writes only if all three hold.

Replace a large block cheaply

view_lines     {"path": "app.py", "start": 40, "end": 75}    → copy the first and last line
replace_lines  {"path": "app.py", "start": 40, "end": 75, "new_text": "…",
                "expect_start_line": "    def handle(self, request):",
                "expect_end_line": "        return response"}

Insert into a run of identical lines (no anchor can name one of them)

view_lines   {"path": "layout.txt", "start": 40, "end": 50}   → they are all "  </div>"
insert_lines {"path": "layout.txt", "before": 45, "new_text": "  <hr>", "expect_line": "  </div>"}

Add a decorator above a function

insert_text {"path": "app.py", "anchor": "def slow_path(self):", "where": "before", "new_text": "@lru_cache(maxsize=None)"}

Add an import

insert_text {"path": "app.py", "anchor": "import os", "where": "after", "new_text": "import sys"}

Delete a line entirely (two ways — pick by what you know)

replace_text {"path": "app.py", "old_text": "print('debug')\n", "new_text": "", "expect_count": 1}
delete_lines {"path": "app.py", "start": 42, "end": 42, "expect_start_line": "print('debug')", "expect_end_line": "print('debug')"}

Blank out a line without removing it

replace_text {"path": "app.py", "old_text": "print('debug')", "new_text": "", "expect_count": 1}

Convert a file from CRLF to LF while editing it

replace_text {"path": "app.py", "old_text": "x = 1", "new_text": "x = 2", "expect_count": 1, "newline": "lf"}

Edit a CRLF file with a multi-line anchor — write plain \n; matching happens on normalized text, so you never type \r\n:

replace_text {"path": "app.py", "old_text": "if ready:\n    go()", "new_text": "if ready:\n    go_fast()", "expect_count": 1}

Read a big file in chunks

view_lines {"path": "huge.log", "start": 1, "end": 0}      → capped at 2000, header says to ask for 2001-…
view_lines {"path": "huge.log", "start": 2001, "end": 0}

Line endings and encodings

Line endings are normalized for matching and restored on write. Reading turns every \r\n, \r and \n into \n, so your old_text, anchor and boundary assertions are always written with plain \n regardless of what is on disk. Writing uses the file's own majority ending unless you pass newline.

  • Detection is a single pass that tallies all three kinds; \r\n counts once.
  • A file with no line break at all is written as lf.
  • ⚠️ A write rewrites the whole file, so a file with mixed endings is normalized to the majority. Every result names the ending used, so this is stated rather than silent.
  • The line model is text.split("\n"), which round-trips exactly — a trailing newline is never silently added or removed, because such a change pollutes every later diff.
  • ⚠️ A file ending in a newline therefore has a final empty line: "a\nb\n" is three lines, and line 3 is "". The same model is used by every tool, which matters more than a prettier count — an off-by-one between tools would be the actual bug.

Encoding is decided by BOM only.

  • auto (default): UTF-8 BOM → utf-8-sig; UTF-16 LE/BE BOM → that codec; otherwise UTF-8. Content is never sniffed, because guessing wrong rewrites the file as mojibake and guessing right earns nothing.
  • An explicit encoding is forced, and a BOM is then treated as content.
  • The BOM is stripped on read and restored on write, so it never appears in text you match against and never disappears from the file.
  • UTF-32 is refused, not decoded. FF FE 00 00 starts with the UTF-16 LE BOM, so guessing would produce NUL-laced garbage and write it back. Pass encoding explicitly if you mean it.
  • Undecodable bytes are refused, never replaced with U+FFFD — a replacement character would be written into the real file on the next save.

What it refuses, and why

Every one of these writes nothing. There is no half-applied outcome.

Refusal Reason
path outside TEXT_EDIT_ROOTS the boundary; applies to reads too
path that does not exist these tools edit existing files — they never create one
path that is a directory not a file
expect_count mismatch you asserted a number and the file disagrees
expect_count < 1, or a bool replacing nothing is not an edit
empty old_text would match nothing meaningfully
old_text == new_text a no-op is a mistake, not a change
anchor matching ≠ 1 line an ambiguous insertion point has no safe reading
where not before/after no third option
expect_start_line / expect_end_line / expect_line mismatch the line numbers are stale
before past the last line appending has no line to assert — that is append_text
start < 1, end < start, end past the file not an addressable range
non-integer start / end (including True) a bool is an int in Python; refused explicitly
unknown newline / encoding refuse rather than pick something
a file that will not encode as requested e.g. CJK text into ascii

Two more properties worth stating:

  • Writes are atomic. Content goes to a temporary file in the same directory and is moved into place with os.replace, so a crash leaves the original intact rather than a truncated file. The original's permissions are copied to the temporary file first — without that, every edit would quietly turn a 0644 file into a 0600 one on Linux.
  • No external-change detection. Being stateless means another process could write between this server's read and its write. Addressing bounds the damage: if the file changed such that your anchor or your boundary lines no longer match, the edit is refused.

Cross-platform notes

The same source runs on both platforms; these are the places where that took explicit work.

Concern Handling
TEXT_EDIT_ROOTS separator os.pathsep — ; on Windows, : elsewhere
Path case containment compares through os.path.normcase, so D:\Work and d:\work are one directory
Prefix collisions containment compares path segments, so /work-other is not inside /work
CRLF detected, preserved, and convertible — see above
Console encoding all three streams are forced to UTF-8; a CJK directory name in a stderr line would otherwise kill the server on a cp950 console
stdout newline forced to \n; Windows text mode would otherwise emit \r\n and put a stray CR in every JSON-RPC frame
File modes copied from the original on write; a no-op on Windows
Symlinks resolved before the containment check on both platforms

Two tests are skipped on Windows and say why: POSIX file modes, and creating a symlink (which needs privileges there).


Running the tests

pytest is the only development dependency:

python -m pip install -r requirements-dev.txt
python -m pytest tests/ -q

191 tests, of which 2 are POSIX-only (they skip on Windows). There is no packaging step — the conftest.py at the repository root is what puts text_edit on sys.path.

The suite is deliberately layered:

File Layer Why it exists separately
test_edits.py pure strings every edit boundary (trailing newlines, overlaps, counts, stale assertions) is decidable without a filesystem
test_text_io.py real files line-ending and BOM round-trips, and that a refused write leaves the file alone
test_paths.py the boundary every test is about the direction of a mistake
test_line_view.py pure rendering window clamping and truncation reporting
test_diff_render.py pure rendering includes a regression for a content line starting with --
test_replace_semantics.py one narrow question is the count I asserted the count that happened? Overlaps, seams, and a brute-force invariant
test_tools.py tool handlers "refused" must mean "unchanged" — each of these reads the file back
test_mcp_protocol.py a real subprocess unit tests cannot catch a stray print on stdout, a CRLF frame, or an answered notification

The protocol tests start the actual server over a pipe and edit real files through it, because a green unit suite proves nothing about a transport.

All four safety guards are mutation-tested, and the experiment is re-run whenever one is touched — a guard nothing notices the absence of is not a guard:

guard removed tests that go red
path containment (paths.is_path_inside_any_root) 4
expect_count assertion 3
the shared line-staleness assertion (all three line tools use it) 7
insert_lines' upper bound 1
replace_text_batch's all-or-nothing discard 4

Troubleshooting

Message What it means
refusing to start — TEXT_EDIT_ROOTS entry '…' is not an existing directory typo, or a relative path resolved from an unexpected working directory. Exit code 2
refusing to start — … is a filesystem root TEXT_EDIT_ROOTS was / or C:\. Name the actual project directories
refusing to start — no allowed directory unset TEXT_EDIT_ROOTS and the working directory is a filesystem root
… is outside the allowed directories (…) the path resolved outside every root. Check for a symlink, or add the directory
… does not exist (a relative path resolves against …) relative paths use the first root; pass an absolute path if you meant another
expected 1 occurrence(s), found 0 old_text is not in the file verbatim — whitespace and indentation count. find_text a shorter fragment to locate it
'anchor' must match exactly once, found 5 lengthen the anchor; the Retry: line gives you a window to copy it from
line 8 does not say what 'expect_start_line' asserts the file moved under you. view_lines the range and copy the real lines — the Retry: line is that call
this file starts with a utf-32-le BOM pass encoding explicitly
cannot decode this file as utf-8 binary, or a legacy codec — pass encoding (e.g. cp950, latin-1)
the client reports the server as broken check stderr. Anything on stdout other than JSON-RPC frames corrupts the stream

Relationship to line_editor

line_editor (in hermes-agent, tools/line_editor_tool.py) is the sibling project this one grew out of. They are for different consumers:

line_editor text-edit (this repo)
Addressing line numbers only text anchors and line numbers, for every operation
Reproduce old content never anchors: all of it · lines: two boundary lines
Staleness handling a viewed-window queue forcing bottom-up edits self-validating addresses; no state at all
State per-task windows + a file cache none
Built for a small local model that paraphrases a model that reproduces text exactly

Taken from it deliberately: one tool per operation with flat, fully-required arguments (three earlier iterations there proved that an envelope is what a weak model fills in wrongly); a unified diff on every edit; errors carrying a copyable corrected call; BOM-only encoding detection; newline controlling only what is written; view_lines' end=0 meaning "to the end"; and "" meaning one blank line rather than zero lines.

Deliberately not taken, with reasons in docs/design/text-edit-tools.md §9: the viewed-window interlock, the file cache, shell-based remote transport, and container path mapping.


Layout

mcp_server.py                 JSON-RPC over stdio; knows nothing about editing
conftest.py                   puts text_edit on sys.path for pytest
text_edit/
  paths.py                    which paths may be touched          (the boundary)
  text_io.py                  bytes ↔ matchable text               (endings, encodings, atomic write)
  edits.py                    the edits, as pure functions         (zero IO)
  line_view.py                a numbered window
  diff_render.py              two versions → unified diff
  outline.py                  a file's lines → which of them are structure
  tools.py                    the ten tools + their schemas
tests/                        one file per module, plus a real-subprocess protocol test
docs/design/text-edit-tools.md   the authority: every decision and every rejected alternative

One module, one question — which is the main criticism this repo's author leveled at line_editor's 1401-line single file, so it seemed only fair to do it properly here.

About

Zero-dependency MCP server giving AI agents ten file-editing tools. Address an edit by text anchor or by line number — both self-validating — and every edit returns a unified diff. Stateless, atomic writes, never half-applied. Python 3.9+, stdlib only, Windows and Linux.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages