cd /news/developer-tools/octofs-mcp-0-9-0-line-42-is-a-lie Β· home β€Ί topics β€Ί developer-tools β€Ί article
[ARTICLE Β· art-93952] src=muvon.io β†— pub= topic=developer-tools verified=true sentiment=Β· neutral

Octofs MCP 0.9.0: Line 42 Is a Lie

Octofs 0.9.0 introduces content-verified line addressing, where every line is rendered as N:hh (position plus an 8-bit FNV-1a hash of its content), and edit tools verify the hash before writing. This prevents edits from landing on the wrong lines when files change between view and edit, a failure mode that previously caused silent corruption. The release also includes two other changes that embody the same principle of self-verifying references.

read10 min views1 publishedAug 12, 2026
Octofs MCP 0.9.0: Line 42 Is a Lie
Image: source

The agent asked to replace lines 40 through 44. It got lines 40 through 44. They were not the lines it had read.

Nothing crashed. No error surfaced. Somewhere between the view

that produced the plan and the batch_edit

that executed it, a formatter had run and shifted the file down by three lines β€” and the edit landed cleanly on five perfectly innocent lines of code. The model saw a success response, reported the refactor as done, and moved on. We found it in review, twenty minutes later, by reading a diff that made no sense.

That's the failure mode this release exists to kill.

0.9.0 makes every line address content-verified: a line is N:hh β€” its position plus a hash of what's on it β€” and every edit tool checks the hash against the file before it writes a byte. A stale target now fails loudly, with the current content in the error. It cannot land on the wrong line, because "the wrong line" no longer matches.

Two other changes shipped alongside it, and they turn out to be the same idea wearing different clothes. More on that at the end.

Line numbers are the wrong primitive #

Here's the thing about a line number: it is only true at the instant you read it.

An agent's edit loop is a sequence of separate MCP calls with gaps between them, and in those gaps the file is not frozen. Another tool call edits it. A formatter runs on save. A parallel agent touches the same file. The human, watching the session, fixes a typo. By the time the edit arrives, "line 42" points at whatever happens to be sitting in the 42nd slot β€” and a filesystem server that takes a bare integer has no way to know the difference between the line the model meant and the line it's about to destroy.

The usual mitigation is a whole-file staleness gate: stamp the file when it's viewed, refuse the edit if the mtime or hash changed. We had a version of that. It's blunt in both directions. It rejects edits to line 900 because someone touched line 3, and it forces a full re-read to recover β€” expensive, and the re-read is itself immediately stale. Worse, it says nothing useful. "The file changed" leaves the model with one move: view the whole file again and hope it wins the race this time.

The primitive was wrong. A line reference should carry enough information to check itself.

N:hh

β€” position plus proof

In 0.9.0, view

renders every line as N:hh|content

:

1:a3|fn main() {
2:f1|    println!("Hello");
3:0e|}

N

is the 1-indexed position. hh

is two hex characters β€” an FNV-1a hash of the line's content, folded from 32 bits down to 8. Edit tools take these composite IDs back as targets, and verify_line_id

checks the hash against the file at apply time. Match, and the edit proceeds. Mismatch, and nothing is written.

The hash covers content only, never position. That looked like a detail when we wrote it and turned out to be the whole design. Because a line keeps its hash when it moves, a failed verification can go looking for where the content went β€” scan the file for lines with the expected hash, and you know that the target didn't vanish, it slid down by three.

Which is exactly what the error says:

Stale line id "42:c7" β€” the file changed since you viewed it. Current content around line 42:
40:1b|    let config = load_config()?;
41:9f|    let client = Client::new(&config);
42:2e|    tracing::info!("client ready");
43:0a|
44:5d|    run(client).await
Content matching hash c7 is now at: 45:c7 (your target may have moved).
Retry with the fresh ids above, or run `view` with start: 40, end: 44 (or a wider range) to confirm before editing.

Three things are in that message, and each one is deliberate. The current content around the target, with fresh IDs β€” so the model can retarget immediately. Where content matching the expected hash lives now, nearest candidates first β€” so a moved-but-otherwise-untouched line is a one-step fix. And a concrete view

range to run if it wants to confirm rather than guess.

The model recovers from the error alone. No re-read of a 2,000-line file, no second race, no burned context. The error is the recovery instruction.

And because edit results come back as diffs with freshly computed IDs, edits chain. Do three batch_edit

calls in a row and the second one's targets come from the first one's response β€” the file never needs re-viewing between them.

There is one honest trade-off, and it's in a comment in the source rather than buried: eight bits means a changed line keeps its hash with probability 1/256. We took it. The IDs stay short enough to be cheap in context and readable in a transcript, the position check catches every kind of gross drift, and the alternative β€” longer hashes on every line of every file view β€” costs tokens on literally every read to defend against a 0.4% case that the diff-back-with-fresh-IDs loop tends to surface anyway.

We also deleted the mode switch. Previous versions had a --line-mode

flag choosing between number-based and hash-based addressing. That flag is gone, and the N:hh format is mandatory β€” this is the breaking change in 0.9.0. Two addressing modes meant every tool description had to explain both, every model had to figure out which one it was talking to, and the safe mode was opt-in. Safety that ships behind a flag is safety most people never turn on.

Plain integers still work where a position alone is genuinely safe and unverifiable-by-nature: view

ranges (negatives count from the end), and the insert anchors 0

for file start and -1

for append. Everything that targets existing content requires an ID.

The edit that succeeded and did nothing #

While we were in there, we found a quieter version of the same bug.

str_replace

matches progressively: exact match first, then a whitespace-normalized fuzzy pass for when the model's indentation drifted. On a file with CRLF line endings, the fuzzy pass would match on normalized text, then try to splice the replacement back into the raw content β€” where every line still ended in \r\n

, so there was nothing to splice. It wrote the file back byte-identical and reported success with a diff. A Windows developer's edit went through the entire motion and changed nothing.

Now all matching happens in LF space and restore_endings

puts \r\n

back on write. Same for batch_edit

. The file keeps its endings; the matcher stops caring about them.

The full match ladder in 0.9.0 runs: exact β†’ escaped-literal recovery β†’ whitespace-normalized fuzzy with indentation adjustment β†’ diagnostics. That second stage is new and is pure model-failure-mode engineering: when a model double-escapes its JSON and sends a literal backslash-n instead of a newline, we interpret the escapes, and if that matches uniquely we apply it and attach a hint saying what we did. It's a mistake models make constantly, it's unambiguous when it happens, and bouncing an error back for it was costing a round trip to fix nothing real.

replace_all

is new too β€” the rename-style edit that used to require either enough surrounding context to make each occurrence unique, or a batch_edit

with one operation per site. And when an exact match hits multiple times without replace_all

, the error now lists every match location as a line ID:

Found 3 matches for replacement text at:
  1. 12:a3
  2. 88:a3
  3. 140:a3
Add more surrounding context to make a unique match, pass `replace_all: true` to replace all 3 occurrences, or use `batch_edit` with the specific line ids.

Three named exits, and every one of them is executable without another view

. That's the pattern, again.

Hints are advice, and models take advice selectively #

The third change is the one that will annoy someone, so let me make the case for it.

Octofs detects shell misuse β€” the model reaching for cat

, grep

, find

, ls

, sed

, or awk

when a dedicated MCP tool does the job better. Until 0.9.0 that detection was configurable via --hint-mode

: warn softly, or reject. Soft was the default as of 0.8.1.

Soft hints don't work. A warning appended to a successful response is a suggestion competing against a result the model already has in hand, and the result wins. We watched sessions where the same hint fired six times and the model kept running grep

, because grep

returned output and the hint cost nothing to ignore.

In 0.9.0 shell misuse is always a hard error, and the mode switch is gone. The call fails, nothing executes, and the error names the tool to use with a worked example:

Searching file text with this command is forbidden β€” use `view` with content= instead
(gitignore-aware, context lines, line numbers, works on remote hosts).

  Example:
    view path="src/main.rs" content="fulfill_input_requests"
    view path="src/" content="TODO" regex=true
    view path="ssh://user@host/dir" content="TODO"  # remote search β€” no `ssh grep` needed

This isn't tidiness. view

with content=

returns line IDs the edit tools accept, respects .gitignore

, and transparently works against ssh://

paths. Raw grep

output gives the model a line number β€” which, per everything above, is a lie waiting to happen β€” and silently drowns it in node_modules

. The dedicated tool is strictly more useful, so the only question was whether to make choosing it optional. It isn't anymore.

What still works: pipelines. cargo build 2>&1 | grep error

is a stream transform, not a file read, and the detector deliberately doesn't split on |

. It splits on ;

, &&

, ||

, newlines, $(

, and backticks β€” and only outside quotes, so ssh host 'cd /path && ls'

doesn't false-positive on a remote command it has no business inspecting. Env-var prefixes are skipped to find the real program, and /bin/grep

is resolved down to grep

so a path prefix isn't an escape hatch.

What these three have in common #

Look at them together and it's one change made three times.

An MCP server's real interface isn't its tool schema β€” it's every string it hands back to a model, and most of those strings are errors. For a human, an error is a notification; you read it, you go fix things by hand. For an agent, an error is a prompt. It's the entire input to the next decision, arriving with no other context, and the quality of what happens next is bounded by what that string contains.

So: don't say "the file changed," say what it changed to and where the content went. Don't say "3 matches found," list them as targets the next call can consume. Don't hint that grep

is discouraged, fail and hand over the exact view

call that replaces it. Every one of those is the same move β€” spend a few hundred characters in the error to save a whole round trip and the context window it burns.

This is what we mean when we say we're aligning the server to the model rather than to the filesystem. Not prompt engineering. Interface design for a caller that recovers by reading, and only reads what you give it.

Upgrade #

brew upgrade muvon/tap/octofs

cargo install octofs --version 0.9.0

Pre-built binaries for Linux, macOS, and Windows (x86_64 and ARM64) are on the releases page, and the release pipeline now publishes to npm alongside crates.io and the MCP registry.

One breaking change to know about: if you set --line-mode

or --hint-mode

in your MCP client config, remove them β€” both flags are gone and the binary will reject them. There is nothing to replace them with; the safe behavior is now the only behavior. No other config changes.

After upgrading, the difference shows up on the first edit that races something else. Instead of a silent success on the wrong lines, you get an error your agent can act on without reading the file again.

Octofs is open source (Apache 2.0) at github.com/Muvon/octofs. If you want the security angle on why an agent should get a scoped filesystem tool instead of a raw shell at all, that's a separate post β€” this one was about making sure the tool edits the line it was actually pointed at.

── more in #developer-tools 4 stories Β· sorted by recency
── more on @octofs 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain β€” perfect for shipping the agent you just read about.

$git push zahid main
β†’ Live at https://your-agent.zahid.host βœ“
Get free account β†’ Pricing
from €0/mo Β· no card required
LIVE [news/octofs-mcp-0-9-0-lin…] indexed:0 read:10min 2026-08-12 Β· β€”