Chapter 2 of 8 · 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:
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:
[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\nThe 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.
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.
#!/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.logThe 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:
#!/bin/sh
exec /opt/repo-index/bin/repo-index-mcp --root "$1" 2>>/tmp/repo-index.logWorking 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.
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:
$ 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 -5Sending 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:
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:
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/initializedWith 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