Skip to content
Chapter 2 of 8 · TransportsThe stdio message stream

14 min read

The stdio message stream

On this page

The stdio transport gives a server two streams and assigns them different owners. The transport writes each JSON-RPC message to standard output as one line of UTF-8 terminated by a newline, and the client reads that stream a line at a time and parses each line as a message. The specification is blunt about the consequence: the server must not write anything to stdout that is not a valid protocol message, and messages must not contain embedded newlines. Standard error is left alone, which is why the protocol says a server may write whatever UTF-8 it likes there. There is one channel for the protocol and one channel for humans.

Why the rule has to be that absolute is worth deriving, because "do not write to stdout" sounds like a style guideline and is in fact a framing requirement. A pipe delivers a stream of bytes and nothing else; it carries no record of where one message ended and the next began, so the reader is handed bytes and has to decide the boundaries itself. Stdio takes the cheapest available answer: a message is everything up to the next newline. That choice buys a framing layer of two lines and costs exactly one thing, which is that the newline can no longer appear anywhere except at the end of a message. Two consequences follow with no further assumptions. A message may not contain an embedded newline, because that would end it early. And a line that is not a message cannot exist, because the reader has no way to tell the two kinds apart until it has already tried to parse one as JSON and failed.

That split is the whole of the rule, and the reason it needs a chapter is that the most ordinary line of debugging code in any language writes to the first channel. print(), console.log, System.out.println, fmt.Println and a logging library configured with an explicit stdout handler all put bytes on the wire, and none of them fail a unit test, because a unit test calls the handler directly and never establishes a transport at all.

One channel for the protocol, one for humans

Here is the shape of the defect, in a handler that logs its own progress:

python
def search_repo(query: str, root: str) -> list[Hit]:
    print(f"[repo-index] searching {root} for {query!r}")
    hits = ripgrep(query, root)
    print(f"[repo-index] {len(hits)} hits")
    return rank(hits)

Attaching to the pipe and dumping the bytes makes the failure legible in a way that no timeout adjustment does:

text
[repo-index] searching /Users/dana/src/monolith for 'parseConfig'\n
{"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"..."}]}}\n
[repo-index] 8 hits\n

The client reads line one, fails to parse it as JSON, and now has to decide what to do with garbage on a stream it is supposed to trust. Implementations differ, and all of the outcomes are bad in a different way. Some clients log a parse error to their own console — which the developer never opens — and skip the line, so the session limps along until the day a print() lands mid-response. Some treat a malformed frame as a fatal protocol violation and tear down the connection, which produces the "connects and then nothing" report. Some are reading with a decoder that expects the first bytes of the stream to be a valid message and stall in a partial-parse state that never resolves. From the outside all three look identical: the process launched, and no messages arrived.

The fix is one change, and it is not "use a logging library" in the abstract, because a logging library configured with a StreamHandler and no arguments writes to stderr already, while one configured with StreamHandler(sys.stdout) reproduces the bug with better formatting. The fix is to send the log lines to the stream the transport leaves free.

python
import logging, sys

logging.basicConfig(stream=sys.stderr, level=logging.INFO,
                    format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("repo-index")

def search_repo(query: str, root: str) -> list[Hit]:
    log.info("searching %s for %r", root, query)
    ...

The same fault wearing three other costumes

Once you know that stdout is the protocol, a family of failures that look unrelated collapses into one. Buffering is the most common member. Python's sys.stdout is line-buffered when it is attached to a terminal and block-buffered with an 8 KiB buffer when it is attached to a pipe, and a client always attaches a pipe. An initialize response for a server with three tools runs about 700 bytes, so a transport implementation that writes without flushing leaves that response sitting in userspace until 8 KiB have accumulated, which for a server answering a handful of calls means never. The developer's own terminal test passes, because a terminal is line-buffered, and the client's launch hangs. Any correct transport implementation flushes after each message; the failure shows up in hand-rolled servers, in wrapper layers, and any time somebody re-wraps sys.stdout in their own TextIOWrapper without preserving the flush discipline. Setting PYTHONUNBUFFERED=1 in the client's env block is a legitimate belt-and-braces measure and a poor primary fix, because it hides the problem for Python and leaves it for every other runtime.

Two steps in that are worth making explicit, because they are what let you predict the next case instead of remembering this one. The first is why the buffering mode changes at all. The C library picks it when the stream is set up, by asking the operating system whether the file descriptor refers to a terminal. An interactive stream is flushed line by line, because a human is watching and a half-written line is no use to them. A pipe is filled towards a block boundary before anything is written, because the program on the other end is not watching and a smaller number of larger writes costs less. Neither choice is a mistake; each is correct for the case it was made for, and a client attaching a pipe silently selects the second one.

The second step is the size, and it is the reason the symptom is silence rather than lateness. Dividing the 8 KiB buffer by a 700-byte initialize response says it takes somewhere around a dozen messages of that size to fill the buffer once. A server that answers a handshake and a handful of tool calls and then waits for the next request has put well under half a buffer into it, so nothing is written, and nothing will be written until either the buffer fills or the process exits. "Flushed when the buffer fills" and "never flushed" are the same observable behaviour at that volume. That is also why the bug appears to be about the first message: the first message is simply the one whose absence stops everything else from happening.

Framing is the second costume. A message must occupy exactly one line, so a transport that serialises with json.dumps(msg, indent=2) for readability turns one message into fourteen lines, of which thirteen are parse errors and one is a fragment. On Windows, opening the stdout stream in text mode translates \n into \r\n, and a strict line reader on the other end either includes the stray \r in the payload or, if the client splits on \r\n while the server writes bare \n, never sees a complete line at all. Neither of these reproduces on a machine that has already been used to develop the server, which is the property that makes them expensive.

The line-ending case has one more step in it than it looks, and skipping the step is how people conclude the problem is a bug in the client. Text mode on Windows exists to translate the abstract line ending a program writes into the platform's concrete one, so the runtime turns each \n the transport emits into the two bytes \r\n on the way out. The transport did not ask for that and cannot see it — it wrote one byte and two were sent. The reader on the other end then does one of two things, and both are defensible. A reader that splits on \n gets a complete line with a stray \r welded onto its end, which lands inside the payload and fails the JSON parse on a character that renders as nothing. A reader that splits on \r\n and is talking to a server writing bare newlines never sees a terminator at all and waits forever. The same code, run on the machine that wrote it, does neither, because there the translation does not happen — which is exactly why "works for me" is not evidence about the server.

The third costume is the wrapper script, and it deserves the detail because it is written with good intentions.

bash
#!/bin/sh
# launch-repo-index.sh — capture everything for support
exec /opt/repo-index/bin/repo-index-mcp --root "$1" 2>&1 | tee /tmp/repo-index.log

The 2>&1 is the fatal part. It redirects stderr into stdout, so every log line the server carefully sent to the free channel is now interleaved into the protocol channel, and the more diligent the server's logging is, the more thoroughly the session is destroyed. The pipe to tee adds a second problem underneath it: tee buffers, so even correct messages arrive in bursts rather than when they were written, and the process at the end of the pipeline no longer has stdout connected to the client directly. There is a third, quieter problem: a wrapper that is not exec-ed — exec replaces the shell with the server rather than starting it as yet another child — leaves a shell between the client and the server, so signals sent to terminate the session reach the shell and the real server survives. And if the wrapper is sourced by a shell that prints a message of the day or a version-manager warning, those bytes arrive on stdout before the server has written anything at all, which is the classic case where the first frame the client sees is Node.js v18.19.0 detected.

What those four faults have in common is worth extracting, because it explains why a wrapper is a bad instinct here rather than a merely risky one. The client's contract is with a process whose standard output is the wire and whose standard input is the client's end of that same wire. A wrapper puts another program in between, and each fault above is that program exercising an ordinary shell capability on a channel that is not a channel in the shell's sense: merging two streams, buffering one, staying alive in front of one, or writing a greeting onto one. Not one of the four is a defect in the shell. They are what happens when a byte-exact protocol channel is handed to tools built for text a person will read.

The version of this wrapper that works keeps the two channels separate and stays out of the way:

bash
#!/bin/sh
exec /opt/repo-index/bin/repo-index-mcp --root "$1" 2>>/tmp/repo-index.log

Working from the evidence you actually have

"The process launched and zero messages arrived" is a stronger piece of evidence than it looks, because it eliminates a large family of causes. A server that crashed on startup would have exited, and the client would report a dead process rather than a live silent one. A server that is merely slow would still eventually produce an initialize response, which is why raising the connect timeout buys nothing: it cannot distinguish a slow server from a stream that will never deliver anything, and it converts a fast, legible failure into a slow, confusing one. What survives the evidence is a short list — bytes going to stdout that are not messages, bytes sitting in a buffer that is never flushed, framing the reader cannot resolve, or a server that is alive and blocked before it ever reaches its read loop.

Flowchart: Process alive, zero messages → Any bytes on stdout?; Any bytes on stdout? → none; none → Anything on stderr?; Anything on stderr? → nothing; nothing → Blocked before the read loop: network fetch, lock, sync call; Anything on stderr? → startup logs only; startup logs only → Buffered or never flushed; Any bytes on stdout? → some; some → First line parses as JSON-RPC?; First line parses as JSON-RPC? → no; no → Non-protocol bytes: print, banner, stderr merged in; First line parses as JSON-RPC? → yes; yes → Framing: embedded newlines or CRLF.

You can walk that tree without a client at all, which is the fastest way to separate a server problem from an integration problem. Feed the server a handshake on stdin by hand and watch what comes back:

bash
$ printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{
  "protocolVersion":"2025-11-25","capabilities":{},
  "clientInfo":{"name":"probe","version":"0"}}}' \
  | /opt/repo-index/bin/repo-index-mcp --root . 2>/tmp/err.log \
  | head -c 400 | xxd | head -5

Sending the two streams to different places is the point of the invocation, and xxd prints whatever arrives as hexadecimal so that bytes a terminal would render invisibly stay legible. If stdout is empty and /tmp/err.log has content, the server is alive and its output is going to the wrong place or nowhere. If stdout has content that xxd shows starting with 5b 72 65 70 6f — the bytes of [repo — you have found a stray print(). If it starts with 7b 22 6a and ends 0d 0a, you have a CRLF problem rather than a content problem.

Reading that output needs one fact, after which it is faster than reading the text. xxd prints every byte as two hexadecimal digits, and for anything that came from a keyboard those digits are the character's ASCII code: 7b is {, 22 is a double quote, 5b is [, and 0d 0a is a carriage return followed by a line feed. So 7b 22 6a is the start of {"j, which can only be the opening of {"jsonrpc" and tells you the writer is producing protocol; 5b 72 65 70 6f is [repo, which no JSON-RPC message may begin with and which is therefore somebody's log prefix. The reason to look at bytes rather than at rendered text is that the two faults that matter most here are invisible once rendered. A stray carriage return prints as nothing at all, and a message with no final newline looks, on a terminal, exactly like one that has it.

The reflex the timeout represents is worth naming rather than mocking, because it is what almost everybody reaches for and it is reached for on good grounds. Every engineer has met a system that was merely slow, and in the first few seconds "launched but silent" is precisely what slow looks like. What makes it the wrong instrument here is that the tree above has no branch on duration in it. Every leaf is a state that persists: bytes on stdout that will never parse do not start parsing, a buffer that is never flushed does not fill later, a connect() with no timeout against a blackholed host does not return. A control whose only power is to distinguish "not yet" from "not ever" tells you nothing about a tree in which every outcome is already "not ever" — and it is not free, because the next reproduction now takes a minute of waiting instead of ten seconds.

Alive, and blocked before the read loop

One branch of the tree above is not a stdout problem at all, and it is worth naming separately because every control in this chapter is useless against it. A server that fetches its configuration over HTTPS at startup, from a host that a partner's corporate proxy blackholes, sits in a connect() with no timeout forever. The process is up, the pipes are open, and nothing will ever be written. So does a server that acquires a file lock held by an earlier crashed instance, and so does one that calls a synchronous, blocking function inside an async entry point before the event loop begins servicing stdin. Stderr is the only thing that distinguishes these from a buffering fault, which is an argument for writing to it early.

That argument generalises into a startup banner, which costs nothing and answers most of the questions a support thread would otherwise take three days to ask:

python
import os, sys, platform, logging
log = logging.getLogger("repo-index")

def log_startup_environment() -> None:
    log.info(
        "repo-index %s | py=%s %s | pid=%d ppid=%d | cwd=%s",
        VERSION, platform.python_version(), platform.platform(),
        os.getpid(), os.getppid(), os.getcwd(),
    )
    log.info(
        "stdout: tty=%s encoding=%s newline=%r | stderr tty=%s",
        sys.stdout.isatty(), sys.stdout.encoding,
        getattr(sys.stdout, "newlines", None), sys.stderr.isatty(),
    )

Logging every frame at debug level, with direction and byte count, closes the loop:

text
2026-03-11T09:14:02.118Z DEBUG <- 214B initialize id=1
2026-03-11T09:14:02.121Z DEBUG -> 706B result id=1 (tools=3)
2026-03-11T09:14:02.140Z DEBUG <- 96B notifications/initialized

With those three lines in a partner's log file, the next report is no longer "it connects and nothing happens" — it is "we see the inbound initialize and no outbound result", which points at the writer, or "we see both and the client saw neither", which points at the wrapper between them. The reason this matters more here than in an ordinary service is that the failure is frequently unreproducible on the machine that wrote the server: a stray write from a dependency, a shell profile that greets you, a Windows newline. "Works for everyone else, including me" is evidence about environments rather than evidence about the server.

Stdout under stdio is not a logging destination that happens to be busy; it is a wire with one writer, and the transport is that writer. Everything in this chapter follows from taking that literally — logging goes to stderr, messages are flushed and one line long, wrappers keep the two streams apart, and application code is denied the handle so the rule cannot be broken by accident.

All of it is also a property of a single-user pipe: one reader, one writer, one process, one identity that nobody had to establish. The move to streamable HTTP keeps the same JSON-RPC messages and the same handlers, and it removes that last property. There is no parent whose identity the server can quietly inherit, and one process now answers many people at once.

Transports — read the current version at https://daviddominguez.dev/learn/en/transports/the-stdio-message-stream

Back to top