JSON-RPC

JSON-RPC

A request/response protocol with JSON payloads. Specifies what a request and response look like, leaves transport unspecified. The de facto standard for local IPC where you want a familiar message shape but don't need protobuf's compactness.

DAP is JSON-RPC-shaped (close but not strictly compliant). LSP is JSON-RPC. MCP is JSON-RPC. Lots of tooling speaks it.

The shape

Request

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "search",
  "params": { "query": "from:alice", "limit": 10 }
}

Response (success)

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": { "messages": [...] }
}

Response (error)

{
  "jsonrpc": "2.0",
  "id": 1,
  "error": { "code": -32603, "message": "Internal error", "data": {...} }
}

Standard error codes (per spec):

Notification (fire-and-forget)

{
  "jsonrpc": "2.0",
  "method": "textDocument/didChange",
  "params": {...}
}

No id — no response expected. Pure event push.

Batch

A JSON array of requests. Server returns a JSON array of responses. Lets you send multiple operations in one round-trip. Used in LSP for related operations.

Transport

JSON-RPC says nothing about transport. Common pairings:

Each transport needs a framing layer. Over stdio/socket, use Length-Prefixed Framing or Content-Length headers (LSP/DAP-style). Over HTTP/WebSocket, the transport already frames.

Why JSON-RPC

Two strengths:

  1. Boring on purpose. Every language has a JSON parser. Reading a JSON-RPC trace by hand is feasible. Adding a new client doesn't require generating bindings from a schema file.
  2. Standard error model. Code + message + optional data. Everyone agrees what an error looks like.

Weaknesses:

For local IPC where the human-readability and language-portability matter more than perf, JSON-RPC is the right call. That's mxr's case, lazydap's case, and the X-Protocol family's case (LSP, DAP, MCP).

What "JSON-RPC-shaped" means

DAP technically isn't strictly JSON-RPC compliant — its message structure differs slightly:

// DAP request:
{ "seq": 1, "type": "request", "command": "initialize", "arguments": {...} }

// DAP response:
{ "seq": 2, "type": "response", "request_seq": 1, "command": "initialize", "success": true, "body": {...} }

// DAP event:
{ "seq": 3, "type": "event", "event": "stopped", "body": {...} }

Different field names, but same structural idea: a seq correlates request with response, an event has no request_seq, errors have a success flag.

LSP is closer to literal JSON-RPC compliance. MCP is fully compliant.

When designing new protocols, just use literal JSON-RPC unless you have a strong reason. Tooling (codecs, validators, debug helpers) is broadest for the spec.

What mxr and lazydap do

Neither is strict JSON-RPC. Both use a JSON-RPC-shaped message:

pub struct IpcMessage {
    pub version: u32,
    pub id: u64,
    pub payload: IpcPayload,    // Request | Response | Event | Error
}

id correlates request and response. Events have id: 0. Errors carry a code + message in the same envelope.

The choice to not use literal JSON-RPC was pragmatic: the projects' own envelope is slightly more compact and fits Rust's enum dispatch naturally. Compatibility with JSON-RPC tooling wasn't worth the friction.

If a future bridge needs to expose mxr or lazydap as MCP servers, a thin translation layer turns the internal envelope into JSON-RPC and back.

JSON-RPC libraries

Useful starting points:

For local-IPC use cases, hand-rolling the codec is often simpler than pulling in a library. The protocol is small.

See also