cd /news/developer-tools/waiting-is-not-a-tool-call-making-an… Β· home β€Ί topics β€Ί developer-tools β€Ί article
[ARTICLE Β· art-118930] src=dev.to β†— pub= topic=developer-tools verified=true sentiment=Β· neutral

Waiting Is Not a Tool Call: Making an MCP Server's Shell Event-Driven

An engineer building the open-source MCP filesystem server octofs has made its shell event-driven after an agent's test suite was cancelled by an idle timeout, causing duplicate runs and false failure reports. The fix, spanning releases 0.10.1 through 0.14.1, adds liveness heartbeats and automatic backgrounding of commands that run longer than ten seconds, with notifications when they finish.

read8 min views1 publishedSep 2, 2026

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.

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.

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.

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.

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.

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.

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.

If you build MCP tools, the takeaways transfer directly:

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.

brew upgrade muvon/tap/octofs

cargo install octofs --version 0.14.1

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.

── 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/waiting-is-not-a-too…] indexed:0 read:8min 2026-09-02 Β· β€”