One of our agents ran a test suite. The suite takes four minutes. The MCP client's idle timeout is sixty seconds.
You can see where this is going. At second sixty the client cancelled the call. The process kept running — nobody told it to stop — while the model, holding a cancellation where its test results should be, did the reasonable thing and ran the suite again. Two test suites, same directory, racing each other over the same build artifacts. The second one failed with a locking error, the model reported the tests as broken, and the tests were fine.
In another session the same model, burned before, developed a workaround: run the build, then call sleep 240, then look. A tool call that does nothing, held open for four minutes, so that a different tool call might have something to show. The model had reinvented polling, badly, because we hadn't given it anything better.
I build octofs, an open-source MCP filesystem server, and this incident set the agenda for eleven releases in two weeks (0.10.1 through 0.14.1). The principle behind them is one I keep coming back to: an MCP server's real interface is every string it hands back to the model. These releases apply it to the slowest string of all — the one the model waits for. The shell is now event-driven. Commands start in the foreground, move to the background on their own if they outlast ten seconds, and the client gets a notification when they finish. Nothing blocks, nothing gets killed, nothing runs twice.
First fix: prove the call is alive
The sixty-second cancellation had a shallow cause and a deep one. The shallow one: a shell call is silent by nature. A build that's compiling says nothing on the wire for minutes, and to an MCP client silence is indistinguishable from a hung server. So 0.10.2 added liveness heartbeats — while a command runs in the foreground, octofs emits a progress notification every ten seconds, well below any sane idle timeout, so a single missed beat can't cancel the call.
That stopped the killings. It did not touch the deep problem: the call still blocked. A four-minute test suite still cost four minutes of session time in which the model could do nothing — not read the failing file, not prepare the next edit, not think. Heartbeats make waiting survivable. They don't make it useful.
Second fix: background jobs — and the flag we had to delete
0.11.0 introduced background execution: run a command as a job, get a handle back immediately, collect the output later. Each job is an MCP resource with a URI like octofs://jobs/17342-1, readable at any time for its status and output.
It shipped with a background flag on the shell tool, and that flag was a mistake we recognize in hindsight as a familiar one. In 0.9.0 we deleted a --line-mode switch because safety that ships behind a flag is safety most people never turn on. The background flag was the same bug in a different costume: it asked the model to predict the duration of a command before running it. Models are bad at this in exactly the way you'd expect — cargo build is instant on a warm cache and takes six minutes cold, and the flag turned that unknowable fact into a required decision. Guess background for a fast command and you've added a pointless round trip. Guess foreground for a slow one and you're back to the blocked call we started with.
So 0.13.0 deleted the flag and replaced the prediction with a measurement. Every command starts in the foreground. If it's still running at ten seconds, it is automatically promoted to a background job — the same process, not killed, not restarted. Output capture is durable from the first byte, so crossing the deadline loses nothing: whatever the command printed in its foreground life is sitting in the job's log when you read it later.
The tool call returns immediately at promotion, with a resource link carrying the command as its name — so a client can render "make test … still running" without re-deriving what the job was, even after a context compaction. When the process exits, octofs emits notifications/resources/updated for the job's URI. The client reads the resource once and gets the exit code and the output tail. No polling, no held-open call, no orphaned process.
Two details in that flow earned their place the hard way:
The tail, not the head. A resource read returns at most the last 30 KB of output. Build logs run long, and the verdict — the error, the final test summary — lives at the end. Feeding a model the first 30 KB of a log whose last line says FAILED is how you get a confident report that everything passed.
Two delivery paths. Clients on the 2026-07-28 MCP revision that opened a subscription stream get the completion on it; older clients get the unsolicited push the earlier spec allowed. And since 0.13.0, a client that subscribes late — after the job already exited — gets the completion replayed instead of waiting forever on a notification that fired before anyone was listening.
The foreground window also shrank from thirty seconds to ten in 0.13.0, and that's the auto-promotion paying for itself: when crossing the boundary costs nothing — same process, durable output, a notification at the end — there's no reason to hold the session hostage for half a minute just in case the command finishes at second twenty-five.
Third fix: let jobs run next to each other
0.11.0 was conservative: one job per directory, full stop. Safe, and too blunt — it serialized a build and a log tail that had no business waiting on each other.
0.14.0 narrowed the guard to the one case that's actually a bug: the identical command already running in the same directory. That's not concurrency, that's the double-fired test suite from the opening story, and instead of racing it, octofs rejects it and tells the model precisely what to do:
The same shell command is already running as background job
octofs://jobs/17342-1 (`cargo test`). Wait for its completion — you will
get a resources/updated notification with its output — instead of
starting a duplicate. Independent commands may run concurrently in this
directory.
Distinct commands run side by side. The duplicate gets an error that is, once again, the recovery instruction.
A hard line under all of it
With the server doing the waiting, a model burning a tool call on sleep 240 stopped being a clever workaround and became pure waste. So 0.10.4 added it to the shell misuse list, next to watch and top:
Waiting with a bare `sleep` is forbidden — it burns the whole tool call
doing nothing.
To wait for a condition, poll it in a loop (sleep inside a loop body
is allowed):
until <check>; do sleep 2; done
To wait for a command you started, run it normally; long-running
commands automatically move to the background and notify you when
they finish.
Same policy as 0.9.0's grep rejection: don't hint, fail — and put the correct move in the error. Octofs rejects a bare sleep; a sleep inside an until loop is a legitimate condition poll and passes. watch and top get rejected because they never exit, which in an event-driven shell means they'd hold a promotion slot forever and never deliver a completion.
Fewer places to hallucinate
Around the shell work, five smaller releases kept pulling the 0.9.0 thread — closing gaps where a model could mistake silence or ambiguity for information.
Empty search results say so out loud. A model handed an empty string doesn't reliably conclude "no matches." Sometimes it concludes "the tool failed" and retries; sometimes, worse, it fills the silence with what it expected to find and proceeds as if it had. So the no-match case is a sentence stating what was searched and that zero matches exist.
Tool schemas dropped their null variants (0.10.3). Optional-as-nullable in a JSON schema reads fine to a human and is an attractive nuisance to a model — "path": null is a call that validates nowhere good. Optional now means absent.
Stale line IDs report better (0.10.5). Listing and search got faster (0.10.1) — newline counting on raw bytes, file types from the directory walker, a whole-buffer prefilter. Latency in a tool the model calls hundreds of times per session is a tax on everything.
The remote box, without ceremony
Octofs has spoken SSH/SFTP since 0.8.0 — point a tool at ssh://user@host/path and the agent gets the same verified filesystem on a remote machine. 0.14.0 resolves targets through ~/.ssh/config. Host aliases, a ProxyJump bastion, IdentityFile, IdentityAgent, per-host users and ports — the configuration you already wrote for your own fingers now applies to the agent's connections. The test is simple: if plain ssh box works in your terminal, ssh://box/path works in octofs, bastion and all. (One hop, honestly: we reject multi-hop ProxyJump chains and ProxyCommand with a clear error rather than half-supporting them.)
0.14.1 made misuse detection read inside ssh commands. The grep-rejection story had a remote-shaped hole: ssh box 'grep -r TODO src/' sailed past a detector that respected quotes too politely to look inside them. It now parses the remote command through SSH's options and nesting and applies the same rules. Pipelines stay allowed, interactive SSH stays untouched.
What this means for your MCP server
If you build MCP tools, the takeaways transfer directly:
- A blocked tool call is a liability, not a wait. Return a handle and notify on completion.
- Don't make the model predict durations — measure them. Auto-promotion beat a flag.
- Errors should contain the recovery instruction, not just the failure.
- Silence is ambiguous — say "nothing found" out loud.
- Safety behind a flag is safety nobody turns on. Delete the flag and make the right behavior the only behavior.
Nothing in octofs depends on our agent runtime, by the way. The job resources, the links, the notifications are all plain MCP — any client that follows the protocol gets the event-driven shell for free.
Try it
# Homebrew
brew upgrade muvon/tap/octofs
# Cargo
cargo install octofs --version 0.14.1
# npm
npm install -g @muvon/octofs
No config changes required. One behavioral note: if your prompts or client code passed a background flag to the shell tool, remove it — the flag is gone and promotion is automatic.
Octofs is open source (Apache 2.0) at github.com/Muvon/octofs. The full write-up lives on the Muvon blog: Waiting Is Not a Tool Call.
Top comments (0)