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
- Every edit returns a diff
- Two ways to address an edit
- Install
- Configuration
- The ten tools — with real output for each
- Recipes
- Line endings and encodings
- What it refuses, and why
- Cross-platform notes
- Running the tests
- Troubleshooting
- Relationship to
line_editor
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.
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, usereplace_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 ashermes-agent'sline_editor.
The full reasoning, including every rejected alternative, is in
docs/design/text-edit-tools.md.
Clone or copy the repository anywhere, then register mcp_server.py with your MCP client.
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 mcp add text-edit -e TEXT_EDIT_ROOTS=/home/me/project -- python3 /opt/LineEditor-MCP-Tools/mcp_server.pyThe 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.pyOne line of JSON listing ten tools, plus text-edit 0.1.0 ready · may touch: … on stderr.
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 |
- One directory or many.
TEXT_EDIT_ROOTS=/aandTEXT_EDIT_ROOTS=/a:/bare 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 (
/orC:\) 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.
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
isattycheck. 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_textanchor 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.
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| 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.
| 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.
| 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.
| 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"withold_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, andold_textis still in the file. A successful edit promises the count, not "it is gone now". When you mean "and then again", say so withreplace_text_batch.
| 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.
| 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.
| 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).
| 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.
| 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}}
| 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)
| 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 |
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 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\ncounts 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
encodingis 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 00starts with the UTF-16 LE BOM, so guessing would produce NUL-laced garbage and write it back. Passencodingexplicitly 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.
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 a0644file into a0600one 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.
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).
pytest is the only development dependency:
python -m pip install -r requirements-dev.txt
python -m pytest tests/ -q191 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 |
| 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 |
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.
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.