In MCP's stdio transport, how are JSON-RPC messages framed on stdin and stdout?
answer
- one message, one line
- no header layer at all
- think about what else prints
- the other stream is for logs
- UTF-8, compact JSON, newline-terminated
basics
~10 sMCP stdio framing is newline-delimited JSON: each JSON-RPC message is one line of UTF-8 text ending in a newline, contains no embedded newlines, and stdout carries nothing except MCP messages.
solid answer
~50 sThe client launches the server as a subprocess, writes JSON-RPC messages to its **stdin** and reads messages back from its **stdout**. Framing is deliberately trivial: one message per line, UTF-8 encoded, terminated by a newline, and a message **must not** contain embedded newlines, so you serialize compactly instead of pretty-printing. There is no `Content-Length` header or any other header layer, so a reader simply splits on newlines and parses each line as one complete message. The other half of the rule is exclusivity: **stdout is reserved for MCP messages only**. A banner, a stray `print`, a progress bar or a library warning on stdout corrupts the stream and breaks the peer's parser; diagnostics belong on `stderr`. In practice you also flush after each write, or the peer blocks on a message still sitting in a pipe buffer. This framing is unchanged in MCP revision 2026-07-28.
code
bash · 1 lineprintf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}' | my-mcp-servergo deeper
Be able to state the three rules plainly: one JSON-RPC message per line, UTF-8, newline-terminated, and stdout used for nothing but MCP messages. Say where logs go instead.
Explain why there is no header layer and what follows from that, including compact serialization, escaped newlines inside strings, and matching responses to requests by JSON-RPC id on a shared stream.
Show you have debugged this: flushing and pipe buffering, a single serialized writer so concurrent messages never splice, and hunting down the dependency that printed to stdout and corrupted the stream.
Own the tradeoff. Newline-delimited JSON buys zero-dependency framing and shell-testability at the cost of a fragile exclusivity rule on stdout and no place for transport metadata, which is why per-request _meta exists.
## The channel Under the stdio transport the client is also the process supervisor: it spawns the MCP server as a child process and talks to it over the three standard streams it already owns. The client writes to the child's **stdin**, reads the child's **stdout**, and the child writes logs to **stderr**. There is no socket, no port, no URL and no authorization layer; the operating-system process boundary is the whole perimeter, which is why stdio is the binding used for local servers. ## One message, one line The framing rule is as small as it can be. Each JSON-RPC message is written as a single line of text terminated by a newline character, and messages **MUST NOT** contain embedded newlines. Concretely that means you serialize JSON in its compact form: `{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{...}}\n`. A pretty-printed object spread over several lines is not legal on this transport, because the reader's only delimiter is the newline. Newlines *inside* string values are not a problem, because JSON escapes them as `\n`, which is two ordinary characters in the byte stream. Messages **MUST** be UTF-8 encoded. There is no charset negotiation and nothing to declare it, so an implementation that writes the platform's default 8-bit encoding will produce garbage the moment a non-ASCII character appears in a tool description or a resource body. ## No header layer at all Developers coming from the Language Server Protocol expect a `Content-Length` header and a blank line before each payload. MCP has none of that. This matters beyond parsing: because there is no header layer, everything that the Streamable HTTP binding puts in headers has nowhere to live on stdio. Under revision 2026-07-28 the protocol version and the client capabilities travel in `params._meta` on every single request (`io.modelcontextprotocol/protocolVersion` and `io.modelcontextprotocol/clientCapabilities`), so a stdio line is fully self-describing. Launching the subprocess is not a handshake and not a session; the 2026-07-28 revision removed the `initialize` request entirely. ## stdout is reserved The second MUST is that a server **MUST NOT** write anything to stdout that is not a valid MCP message. This is the single most common way a hand-written server fails on its first run: - a startup banner or version line printed by the framework; - a `print()` or `console.log()` left in from debugging; - a dependency emitting a deprecation warning to stdout; - a progress bar or spinner writing control characters. Any of these lands in the middle of the message stream and the client's line parser sees a line that is not JSON. Everything human-facing goes to `stderr` instead, which the transport explicitly reserves for logging. The mirror-image rule exists on the client side: the client **MUST NOT** write anything that is not a valid MCP message to the server's stdin. ## Direction is asymmetric Revision 2026-07-28 tightened the stream contract further: the client **MUST NOT** write JSON-RPC *responses* to stdin, and the server **MUST NOT** write JSON-RPC *requests* to stdout. Requests flow one way only. A server that needs something from the client returns an interim result from the call it is already serving rather than originating a request of its own. ## Practical failure modes **Buffering.** Pipes are block-buffered by default in many runtimes. If a server writes a response but never flushes, the client waits forever on a message that is physically sitting in a buffer. Flush after every message, or open the streams line-buffered. **Concurrent writers.** Requests may be in flight concurrently and both peers may interleave notifications with responses, but a *line* must be written atomically. Two threads writing to stdout without a lock will splice two messages into one corrupt line. Serialize writes behind a single writer. **Correlation, not ordering.** Because everything shares one stream, responses are matched to requests by the JSON-RPC `id`, not by arrival order. A server is free to answer a later request first. **Binary content.** There is no binary frame type. Non-text resource contents are base64-encoded inside the JSON, which keeps every message a printable single line. ## Why this design Newline-delimited JSON needs no framing library, is trivially testable from a shell (`echo` a line into the process and read one back), and is easy to log and diff. The cost is the exclusivity rule on stdout, which is a discipline problem rather than a protocol problem, and the loss of any place to put headers, which 2026-07-28 answers by making every request carry its own metadata.
- A colleague pretty-prints the JSON before writing it. What breaks?Framing. A pretty-printed object spans several lines, and the reader treats every newline as a message boundary, so it tries to parse `{` alone as a complete JSON-RPC message and fails. Messages must be serialized compactly with no embedded newlines. Newlines inside string values are fine because JSON escapes them as \n, which is two ordinary characters in the byte stream.
- How does a reader tell a response apart from a notification on the same stdout stream?By the JSON-RPC envelope, not by framing. A response carries the `id` of the request it answers plus `result` or `error`; a notification carries a `method` and no `id`. Both share the single stdout channel, so the reader dispatches per line: match `id` to a pending request, or route the notification by its method name.
- Why is flushing after each write more than a performance detail here?Because pipes are usually block-buffered. If the server writes a complete response but the runtime holds it in a buffer waiting for more bytes, the client sees nothing and blocks on a read while the server blocks waiting for the next request. That is a deadlock, not slowness. Flush after every message or open the stream line-buffered.
saying these in an interview costs you the question
- Says MCP stdio uses Content-Length headers like LSP
- Pretty-prints JSON across multiple lines on the wire
- Logs or prints banners to stdout alongside messages
- Assumes responses arrive in request order on the shared stream
- Thinks binary data is sent as raw bytes between messages