ultraworkers/claw-code · error · std::io::Error

MCP stdio stream closed while reading line

Error message

MCP stdio stream closed while reading line

What it means

Client-side `McpStdioProcess::read_line` (runtime/src/mcp_stdio.rs:1214): reading a newline-delimited JSON-RPC line from the spawned MCP server's stdout returned 0 bytes — the child closed its stdout, normally because it exited. Unlike the server-side frame reader there is no clean-EOF path here: ANY EOF is `UnexpectedEof`, even after the server finished its work.

Source

Thrown at rust/crates/runtime/src/mcp_stdio.rs:1214

    pub async fn write_all(&mut self, bytes: &[u8]) -> io::Result<()> {
        self.stdin.write_all(bytes).await
    }

    pub async fn flush(&mut self) -> io::Result<()> {
        self.stdin.flush().await
    }

    pub async fn write_line(&mut self, line: &str) -> io::Result<()> {
        self.write_all(line.as_bytes()).await?;
        self.write_all(b"\n").await?;
        self.flush().await
    }

    pub async fn read_line(&mut self) -> io::Result<String> {
        let mut line = String::new();
        let bytes_read = self.stdout.read_line(&mut line).await?;
        if bytes_read == 0 {
            return Err(io::Error::new(
                io::ErrorKind::UnexpectedEof,
                "MCP stdio stream closed while reading line",
            ));
        }
        Ok(line)
    }

    pub async fn read_available(&mut self) -> io::Result<Vec<u8>> {
        let mut buffer = vec![0_u8; 4096];
        let read = self.stdout.read(&mut buffer).await?;
        buffer.truncate(read);
        Ok(buffer)
    }

    pub async fn write_frame(&mut self, payload: &[u8]) -> io::Result<()> {
        let encoded = encode_frame(payload);
        self.write_all(&encoded).await?;
        self.flush().await

View on GitHub (pinned to 08106b0c37)

Solutions

  1. Run the server command manually with the same args/env from the MCP config and watch stderr — it almost always exits with a message.
  2. Fix the config: correct command/args, and add required env entries to the server's env map.
  3. Treat EOF from read_line as 'server exited', not a retryable IO hiccup — surface stderr instead of looping.

Example fix

# before (config: server exits immediately)
# mcp config: npx -y @modelcontextprotocol/server-everything --bogus-flag

# after (verify manually, then fix args)
npx -y @modelcontextprotocol/server-everything --help   # observe the real failure
# mcp config: args = ["-y", "@modelcontextprotocol/server-everything"]
Defensive patterns

Strategy: fallback

Validate before calling

// smoke-test the server command exactly as configured before wiring it in
// (run it, send nothing, confirm it stays alive and prints framed output on stdout)
Command::new(&transport.command).args(&transport.args).envs(&transport.env).spawn()?

Try / catch

match process.read_line().await {
    Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => {
        let _ = process.child.wait().await;   // collect exit status
        // report "MCP server <name> exited" with captured stderr; do NOT retry blindly
    }
    other => other,
}

Prevention

When it happens

Trigger: The configured MCP server binary crashing on startup (bad args, missing env like an API key, unsupported Node version — npx wrapper exits immediately); the server exiting after an error; calling read_line after the server already shut down.

Common situations: `npx -y @some/mcp-server` where the package fails to resolve; server requiring an env var that was not passed in the MCP config `env` map; server printing a fatal error to stderr and exiting while the client is blocked reading stdout.

Related errors


AI-assisted analysis of ultraworkers/claw-code@08106b0c37 (2026-08-18). Data as JSON: /api/errors/913b134f8219be34. Report an issue: GitHub.