TAI BUI
Back to curriculum
CapstonePython

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.

TIME: ~90 minutesPREREQUISITES: Phase 13 lessons 01-07, Phase 14 lesson 01

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.

Check: Why JSON-RPC stays the lingua franca0/2
1. What is JSON-RPC 2.0?Define
2. Why can JSON-RPC work for a coding agent that talks to many separate tool servers?Explain

The wire shape

Four envelope shapes exist. The client speaks two of them (request, notification) and the server speaks the other two (response, error).

Mermaid diagram

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.

Check: The wire shape0/5
1. What is a notification in JSON-RPC?Define
2. What is a batch in JSON-RPC?Define
3. What does the server return when a batch contains only notifications?Explain
4. Why must a server never reply to a notification?Explain
5. A client sends {"jsonrpc":"2.0","method":"math.add","params":{"a":1,"b":2}} with no id and waits forever for the answer. What is wrong?Analyze

The five error codes

JSON-RPC defines five standard error codes. Each one says which layer failed.

text
-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 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.

Check: The five error codes0/2
1. Which error code does the transport return when JSON parsing fails?Define
2. Why does a parse error response carry id null?Explain

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.

Check: Newline framing and the BytesIO demo0/2
1. What is newline framing in this transport?Define
2. Why is an io.BytesIO pair a fair stand-in for stdin and stdout in the demo?Explain

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.

text
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. 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.

Check: Method dispatch0/7
1. What is method dispatch, and who performs it in this design?Define
2. If a handler raises a plain ValueError, what does the transport return?Explain
3. Why does the transport not consult the tool registry directly?Explain
4. In the demo, request id 3 calls the method missing, and the handler falls through to raising MethodNotFound. What does the server write for id 3?Apply
5. A handler raises MethodNotFound because no method matches the request. Which error code surfaces to the caller?Apply
6. A client calls a tool with bad arguments and gets -32603 with data.exception "ValueError" instead of -32602. Which change fixes the semantics?Analyze
7. A teammate adds a lookup of tool names inside StdioTransport so it can reject unknown tools itself. What does this design cost?Analyze

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.

text
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.

Check: Stream behavior on errors0/5
1. A line parses as valid JSON, but the object has no method field. Which error code does the transport return?Explain
2. Why does one malformed line not poison the lines that come after it?Explain
3. A broken-JSON line arrives mid-session, followed by a valid request with id 9. What does the server write?Apply
4. Design A wraps the whole serve loop in one try block that exits on any parse failure. Design B replies -32700 with id null and reads the next line. Which holds up for a long session, and why?Analyze
5. You are extending this transport and can write only one test first. Which test protects the most important property?Design

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.

Check: Notifications and asymmetric flows0/3
1. What does write_notification let a handler do while a request is still in flight?Define
2. A handler holding a reference to the transport calls write_notification twice with progress, then returns for request id 7. What does the client read from stdout?Apply
3. A tool runs for a minute and the client wants status along the way, with no extra round trip for each update. Which approach fits?Design
Author's Note

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.

Check: How to read the code0/4
1. The demo handler runs math.add on the request {"id": 2, "method": "math.add", "params": {"a": 5}}, and the handler raises InvalidParams because b is missing. What does the server write?Apply
2. A batch line holds a math.add request with id 11, a log notification, and an echo request with id 10, in that order. What does the server write?Apply
3. The demo sends the single line {"jsonrpc":"2.0","method":"log","params":{"level":"info"}} with no id. What does the server write for it?Apply
4. The demo stdin ends with the line {not json after all the valid requests. What is the last thing the server writes before it returns at EOF?Apply

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.

Warm-up0/3
1. Two programs exchange JSON messages over a pair of pipes. Which is a simple way to mark where one message ends and the next begins?Warm-up
2. A long-running server reads a stream and one line is not valid JSON. More valid lines follow. What is the most sensible behavior?Warm-up
3. A client sends several requests without waiting for replies. What does each request need so the client can match replies to calls?Warm-up
Check: Going further0/1
1. You need clients to cancel an in-flight call without changing the wire format. Which design fits?Design

Code