Phase 19 · Lesson 20 of 85

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.

Diagram (opens full size)

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

  1. What distinguishes a JSON-RPC notification from a request on the wire?

    1. Notifications omit the id field; servers must not respond to them
    2. Different jsonrpc version field
    3. Notifications use a different method namespace
    4. 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.

  2. Why is newline-delimited JSON sufficient for stdio framing?

    1. Because UTF-8 has no newline byte
    2. Because the spec mandates it
    3. Because pipes guarantee newline atomicity
    4. 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.

  3. Which error code does the transport return when JSON parsing fails?

    1. -32600
    2. -32700
    3. -32603
    4. -32601
    Show the answer

    B. -32700 -32700 is Parse error. The response id is null because no id could be extracted.

  4. If a handler raises a plain ValueError, what does the transport return?

    1. It does not return; the connection drops
    2. Invalid params (-32602)
    3. Method not found (-32601)
    4. 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.

  5. What does the server return when a batch contains only notifications?

    1. Nothing — no bytes are written
    2. One null per notification
    3. An empty array
    4. 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.

  6. A line with broken JSON arrives in the middle of a session. What happens to subsequent lines?

    1. They are buffered until the broken line is retried
    2. The transport writes a -32700 response and continues reading the next line as fresh
    3. The transport closes the stream
    4. 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.

  7. Why does the transport not consult the tool registry directly?

    1. To keep the transport layer JSON-RPC-shaped only; the handler stitches transport to registry
    2. Because the registry is async
    3. Because the registry holds secrets
    4. 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.

Next lessonFunction Call DispatcherPrevious: Tool Registry with Schema Validation

Lessons from AI Engineering from Scratch by Rohit Ghumare and contributors, MIT license. Notes for owners by Lozo. License

Next step

Rather have it built for you?

Reading is optional. Tell me what's stuck and I'll build it into the business you already run.

  • A real conversation, not a pitch
  • You keep your accounts
  • Nothing sends without your yes