JSON-RPC 2.0 Over Newline-Delimited Stdio
Tool servers and model clients talk JSON-RPC over stdio. Write the transport by hand once and you'll see exactly what each framing layer buys you.
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.
Why JSON-RPC stays the lingua franca
JSON-RPC 2.0 is a stateless remote-procedure-call protocol: a client sends a JSON request naming a method and its params, and the server answers with a JSON result or error. The spec runs two pages and has been stable since 2013.
That stability is why it still carries the traffic. A coding agent in 2026 talks to maybe twelve tool servers in a single session, and each one is a separate process or a remote endpoint. The alternatives (gRPC, HTTP per call, custom binary) each force a tradeoff JSON-RPC avoids: you get streaming or batching, or you get a protocol tied to one transport. JSON-RPC runs unchanged over stdio, sockets, websockets, and HTTP, so a client can drive a server it has never seen as long as both honor the spec.
This lesson builds the stdio variant, newline-delimited JSON. Each request is one line, each response is one line, and the transport boundary is \n.
The wire shape
Four envelope shapes exist. The client speaks two of them (request, notification) and the server speaks the other two (response, error).
A notification is a request with no id, and the server must not respond to it. A reply would have nothing to attach to on the client side, since no call site is waiting. That one rule keeps the framing math simple.
A batch is a JSON array of requests or notifications. The server replies with an array of responses, one per non-notification entry. The spec lets a server answer in any order, so clients match by id; this code happens to reply in request order. If every entry in the batch is a notification, the server sends nothing back.
The five error codes
JSON-RPC defines five standard error codes. Each one says which layer failed.
-32700 Parse error JSON could not be parsed
-32600 Invalid Request Envelope shape is wrong
-32601 Method not found
-32602 Invalid params
-32603 Internal errorThe range -32000 to -32099 is reserved for server-defined errors, and anything outside the standard set is application-defined. This lesson sticks to the five. When a handler raises, the transport wraps the failure as -32603 and puts the exception class name in data.exception.
Parse errors get one special rule. The response id is null, because the request never parsed far enough to yield an id.
Newline framing and the BytesIO demo
Newline framing means the transport reads one line at a time, where a line is the bytes up to and including \n. If a line can't be parsed, the transport writes a -32700 response with id: null and moves on. The stream isn't poisoned, and the next line gets parsed fresh.
For the lesson, an io.BytesIO pair stands in for stdin and stdout. The server reads requests until EOF, writes a response for each, and returns. The client then reads the responses back. There's no process spawn and no timeout to tune. Behavior matches a real subprocess pipe because Python's io interface gives you the same .readline() and .write() contract.
Method dispatch
Method dispatch is the step that turns a parsed request into a result, and the transport deliberately doesn't do it. It hands off to a callable handler(method, params) that the harness supplies. The handler returns a result or raises, and three exception classes map to specific codes.
MethodNotFound -> -32601
InvalidParams -> -32602
Anything else -> -32603 with exception name in dataThe transport never sees a tool registry; the registry sits behind the handler. That's the layering I want. The transport speaks JSON-RPC, the registry speaks tool shapes, and the dispatcher (lesson twenty-three) stitches them together.
Stream behavior on errors
Stream behavior on errors comes down to one property: a malformed or failing request costs you one error response, not the loop. The table shows a stream with failures mixed in.
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}Broken JSON doesn't stop the loop. Neither does a missing method field or a handler exception. The transport keeps reading until EOF.
Notifications and asymmetric flows
A notification is a fire-and-forget message that expects no reply. The harness uses them for progress events, cancellation signals, and log lines. They let a long-running tool stream status updates without a round trip for each one.
The lesson implements one outbound helper, write_notification, which the server calls to emit progress while a request is in flight. The usage pattern: a handler that holds a reference to the transport calls write_notification twice with progress, and then serve writes the final response.
I'd write the broken-JSON test before anything else and build the loop around it. Per the stream-behavior section, a bad line gets a -32700 reply with id: null and the next line is parsed fresh, so one malformed line doesn't stop the loop.
I'd also keep tool names out of StdioTransport. The method-dispatch section puts the registry behind the handler: the transport speaks JSON-RPC, the registry speaks tool shapes, and lesson twenty-three joins them. My bet is that if you extend this transport, that boundary is where you'd want to hold the line.
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 the loop continues), 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, and none of them change the wire. They add metadata.
First, a correlation id that survives forwarding. Your id already does this job, but in a mesh you also need an outer trace id. Second, a cancellation channel: a notification like $/cancelRequest carrying the id of the in-flight call. Third, a content-type negotiation handshake so one socket can speak both JSON-RPC and Streamable HTTP.
Code
- main.py (python, 259 lines)
- tests/test_transport.py (python, 166 lines)