JSON-RPC 2.0 Over Newline-Delimited Stdio
The transport between a model client and a tool server is JSON-RPC over stdio. Hand-rolling it once teaches you what every framing layer is paying for.
Lesson 20 of 85Build~90 minutesPython
Before this: Lessons 1 to 7 of Phase 13, lesson 1 of Phase 14
On this page
Learning Objectives
- Speak JSON-RPC 2.0 framed as newline-delimited JSON over stdin and stdout.
- Map the five standard error codes (-32700, -32600, -32601, -32602, -32603) and surface them with the right semantics.
- Distinguish requests, responses, notifications, and batches without inventing new envelope keys.
- Handle one parse error per line without poisoning the rest of the stream.
- Build a self-terminating demo using io.BytesIO so the lesson runs without spawning a child process.
cf-jsonrpc-frames
Why JSON-RPC stays the lingua franca
A coding agent in 2026 talks to maybe twelve tool servers in a single session. Each server is a separate process or a remote endpoint. The wire format has been the same since 2013. JSON-RPC 2.0 is two-page spec. It survives because the alternatives (gRPC, HTTP per call, custom binary) all impose a tradeoff JSON-RPC does not: they pick either streaming or batching or transport-coupling. JSON-RPC is symmetric across stdio, sockets, websockets, and HTTP, and a client can drive a server it has never seen if both honor the spec.
This lesson builds the stdio variant. Newline-delimited JSON. Each request is one line. Each response is one line. The transport boundary is \n.
The wire shape
Four envelope shapes exist. Two are spoken by the client. Two are spoken by the server.
A notification has no id. The server must not respond to it. If a server returns a response to a notification, the client has no way to attach it to a call site. That single rule keeps the framing math simple.
A batch is a JSON array of requests or notifications. The server replies with an array of responses, in any order, one per non-notification entry. If every entry in the batch is a notification, the server sends nothing back.
The five error codes
-32700 Parse error JSON could not be parsed
-32600 Invalid Request Envelope shape is wrong
-32601 Method not found
-32602 Invalid params
-32603 Internal error
The codes between -32000 and -32099 are reserved for server-defined errors. Everything else is application-defined. The lesson sticks to the five. If your handler raises, the transport wraps it as -32603 with the exception class name in data.exception.
A parse error has a special rule. The id in the response is null, because the request never parsed enough to extract an id.
Newline framing and the BytesIO demo
The transport reads one line at a time. A line is bytes up to and including \n. If a line cannot be parsed, the transport writes a -32700 response with id: null and continues. The stream is not poisoned. The next line gets parsed fresh.
For the lesson we wrap an io.BytesIO pair as stdin and stdout. The server reads requests until EOF, writes responses for each, and returns. The client reads the responses back. No process spawn. No timeouts. The transport behavior is identical to a real subprocess pipe because Python's io interface presents the same .readline() and .write() contract.
Method dispatch
The transport does not know which methods exist. It hands off to a callable handler(method, params) that the harness supplies. The handler returns a result or raises. Three exception classes surface specific codes.
MethodNotFound -> -32601
InvalidParams -> -32602
Anything else -> -32603 with exception name in data
The transport never sees a tool registry. The registry sits behind the handler. This is the layering we want. The transport speaks JSON-RPC. The registry speaks tool shapes. The dispatcher (lesson twenty-three) stitches them together.
Stream behavior on errors
client writes server reads server writes
--------------- ----------- -------------
{...valid request...} parses ok {...response, id matches...}
{...broken json... parse fails {id:null, error: -32700}
{...valid request...} parses ok {...response, id matches...}
{...missing method...} invalid envelope {id:X, error: -32600}
A broken JSON line does not stop the loop. A missing method field does not stop the loop. A handler exception does not stop the loop. The transport keeps reading until EOF.
Notifications and asymmetric flows
A notification is fire-and-forget. The harness uses notifications for progress events, cancellation signals, and log lines. Notifications are how a long-running tool can stream status updates without round-tripping for each one.
The lesson implements one outbound notification helper, write_notification. The server uses it to emit progress while a request is in flight. The demo shows the pattern: a request comes in, the handler emits two progress notifications, then writes the final response.
How to read the code
code/main.py defines StdioTransport, the parse helper (parse_request), the three write helpers (write_response, write_error, write_notification), and the dispatch loop serve. The error code constants live at module scope.
code/tests/test_transport.py covers the five error codes, notifications (no response written), batches (array in, array out, notifications skipped), broken JSON (parse error then continue), and the asymmetric flow where a handler writes a notification mid-call.
Going further
This transport is enough for the lessons that follow. Production transports add three things. A correlation id field that survives forwarding (your id is already this, but in a mesh you need an outer trace id too). A cancellation channel (a notification like $/cancelRequest with the id of the in-flight call). And a content-type negotiation handshake so the same socket can speak JSON-RPC and Streamable HTTP. None of those change the wire. They add metadata.
Check yourself
Quick quiz
What distinguishes a JSON-RPC notification from a request on the wire?
- Notifications omit the id field; servers must not respond to them
- Different jsonrpc version field
- Notifications use a different method namespace
- Notifications are wrapped in an array
Show the answer
A. Notifications omit the id field; servers must not respond to them Absence of id is the only marker. Servers do not respond, because there is no id to correlate against.
Why is newline-delimited JSON sufficient for stdio framing?
- Because UTF-8 has no newline byte
- Because the spec mandates it
- Because pipes guarantee newline atomicity
- Because JSON does not allow literal newlines inside strings without escaping, so each object can be one line
Show the answer
D. Because JSON does not allow literal newlines inside strings without escaping, so each object can be one line json.dumps emits a single line per object; literal newlines inside strings are escaped. That makes \n a safe record separator.
Which error code does the transport return when JSON parsing fails?
- -32600
- -32700
- -32603
- -32601
Show the answer
B. -32700 -32700 is Parse error. The response id is null because no id could be extracted.
If a handler raises a plain ValueError, what does the transport return?
- It does not return; the connection drops
- Invalid params (-32602)
- Method not found (-32601)
- Internal error (-32603) with the exception class name in data
Show the answer
D. Internal error (-32603) with the exception class name in data Unmapped exceptions surface as -32603. data.exception preserves the class name for the client.
What does the server return when a batch contains only notifications?
- Nothing — no bytes are written
- One null per notification
- An empty array
- A single null response
Show the answer
A. Nothing — no bytes are written Spec rule: if every entry is a notification, the server stays silent.
A line with broken JSON arrives in the middle of a session. What happens to subsequent lines?
- They are buffered until the broken line is retried
- The transport writes a -32700 response and continues reading the next line as fresh
- The transport closes the stream
- They are all rejected with -32600
Show the answer
B. The transport writes a -32700 response and continues reading the next line as fresh The framing layer must not poison the stream. One parse error means one error response and the loop continues.
Why does the transport not consult the tool registry directly?
- To keep the transport layer JSON-RPC-shaped only; the handler stitches transport to registry
- Because the registry is async
- Because the registry holds secrets
- Because the transport runs in a different process
Show the answer
A. To keep the transport layer JSON-RPC-shaped only; the handler stitches transport to registry The transport speaks the wire. The dispatcher (next lesson) is where transport meets registry meets timeout.
Questions people ask
What is JSON-RPC?
JSON-RPC is a simple format for calling functions over a connection. A request names a method, passes parameters and carries an id. The response returns a result or an error with the same id, so the caller can match answers to questions. MCP uses JSON-RPC 2.0.
Why do MCP servers use stdio?
For local servers, standard input and output is the simplest channel. The client starts the server as a process and exchanges one JSON message per line. There is no port to open or network to configure, which keeps local tools easy to install and isolate.
Lessons from AI Engineering from Scratch by Rohit Ghumare and contributors, MIT license. Notes for owners by Lozo. License