Hmbown/CodeWhale · error · io::Error

child stdout was not valid UTF-8

Error message

child stdout was not valid UTF-8: {err}

What it means

After collecting one bounded line from the MCP child's stdout (with any trailing CR stripped), read_bounded_line converts the bytes to String via String::from_utf8; invalid UTF-8 becomes InvalidData with this message. The JSON-RPC-over-stdio transport is defined as UTF-8 text lines, so non-UTF-8 output means the child is not speaking the protocol.

Solutions

  1. Fix the server to write strictly UTF-8 to stdout (set stdout encoding explicitly; on Windows use UTF-8 mode)
  2. Ensure logs/progress go to stderr and stdout carries only JSON-RPC text
  3. Check the child's locale/env (e.g. PYTHONIOENCODING=utf-8, LANG=C.UTF-8)
  4. Confirm the configured command launches the intended MCP server binary

Example fix

// before
print(data_bytes.decode('latin-1'))
// after
sys.stdout.buffer.write(json.dumps(msg).encode('utf-8')); sys.stdout.write('\n')
Defensive patterns

Strategy: validation

Validate before calling

# force UTF-8 stdio in the server's environment
env:
  PYTHONIOENCODING: utf-8
  LANG: C.UTF-8

Try / catch

match read_bounded_line(&mut reader, max_bytes) {
    Err(e) if e.kind() == io::ErrorKind::InvalidData && e.to_string().contains("UTF-8") =>
        Err(anyhow!("MCP server emitted non-UTF-8 on stdout; fix server encoding")),
    r => r,
}

Prevention

When it happens

Trigger: The MCP server writes binary data, non-UTF-8 encoded text (e.g. Latin-1 log lines), or a partially-flushed multibyte character split across chunk boundaries handled incorrectly by the server, into stdout; caught during spawn_with_timeouts message reading.

Common situations: Windows server writing console codepage bytes instead of UTF-8; server emitting a binary banner or BOM-wrapped/handled output; wrong binary launched (e.g. a compiled artifact printing progress bytes); locale env forcing non-UTF-8 output.

Understand the failure class

Background: "Invalid JSON response" and "Failed to parse response" errors: when an API answers 200 but the body isn't the JSON your library expected — this error's family across 28 libraries.

Related errors


AI-assisted analysis of Hmbown/CodeWhale@73e0f67d83 (2026-09-22). Data as JSON: /api/errors/6bb287090d70050e. Report an issue: GitHub.

Appendix: source

Thrown at crates/mcp/src/stdio_client.rs:264

            break;
        }

        if line.len().saturating_add(available.len()) > max_bytes {
            return Err(io::Error::new(
                io::ErrorKind::InvalidData,
                format!("JSON-RPC line exceeded the {max_bytes}-byte limit"),
            ));
        }
        line.extend_from_slice(available);
        let consumed = available.len();
        reader.consume(consumed);
    }

    if line.last() == Some(&b'\r') {
        line.pop();
    }
    String::from_utf8(line).map(Some).map_err(|err| {
        io::Error::new(
            io::ErrorKind::InvalidData,
            format!("child stdout was not valid UTF-8: {err}"),
        )
    })
}

enum ChildStdoutMessage {
    Line(String),
    Invalid(String),
}

fn response_to_server_request(message: &Value) -> Option<Value> {
    let method = message.get("method").and_then(Value::as_str)?;
    let id = message.get("id")?;
    Some(match method {
        "ping" => json!({"jsonrpc": "2.0", "id": id, "result": {}}),
        _ => json!({
            "jsonrpc": "2.0",

View on GitHub (pinned to 73e0f67d83)