Also at Deasil Works · txn2 · Plexara
Profiles GitHub · X · LinkedIn
Theme Light · Auto · Dark
Professional notes by Craig Johnston
long-form, short-form, working drafts · since 2008
VOL. XIX · MMXXVI
106 NOTES IN PRINT
FOLIO LXXXV 2026-05-05 · 10 MIN · LONG-FORM

JSON-RPC 2.0: The Grammar Underneath MCP

Three message shapes, one error object, and a hundred and fifty lines of Go that every MCP transport is built on

Diagram · folio lxxxv
flowchart TD
  M["a JSON-RPC message"] --> Q1{"has a method?"}
  Q1 -->|yes| Q2{"has an id?"}
  Q1 -->|no| Q3{"has an error?"}
  Q2 -->|yes| REQ["request<br>expects a reply"]
  Q2 -->|no| NOT["notification<br>no reply"]
  Q3 -->|yes| ERR["error response"]
  Q3 -->|no| RES["result response"]

MCP is JSON-RPC 2.0 over a transport. The last two posts used that phrase without unpacking it. JSON-RPC 2.0 is a small specification: three message shapes and one error object. This post implements the whole thing in Go, about a hundred and fifty lines, which is the layer the MCP SDK sits on.

This is part of MCP on the Wire, a series that takes the Model Context Protocol apart message by message, in Go. It comes out of building and running MCP servers in production, including the open-source txn2/mcp-data-platform, an Apache-2.0 platform in Go that connects AI assistants to Trino, DataHub, and S3 through one MCP endpoint, enriching every result with semantic context (ownership, lineage, PII, data quality) behind OAuth 2.1 auth, personas, and an audit trail. Everything here is read straight off the wire against spec revision 2025-11-25, with the official Go SDK at v1.6.1.

The first post sent four messages by hand and read the replies. The second caught the server calling back to the client. Every one of those messages was a JSON-RPC message, and every MCP method, tools/call, resources/read, sampling/createMessage, all of them, is one of three shapes with a name dropped into it. Learn the three shapes and you have learned the frame around every primitive in the rest of the series.

JSON-RPC 2.0 is not MCP’s invention, and it is not exotic. The Language Server Protocol that connects code editors to language tooling is JSON-RPC 2.0, and so is the Build Server Protocol behind build tools like Bazel and Gradle, which reuses the identical base protocol. It runs well outside developer tooling too: the Kodi media center exposes a JSON-RPC 2.0 control API. It is a 2013 specification that survives because it is small, and MCP adopted it for the same reason the editor world did: one client speaks to many servers.

§The Whole Grammar Is Three Shapes

Here are all three. A request has an id, a method, and usually params. It expects a reply:

{ "jsonrpc": "2.0", "id": 1, "method": "add", "params": [2, 3] }

A notification is a request with no id. The missing id is the whole signal: it tells the receiver not to reply, because there is nowhere to send a reply to:

{ "jsonrpc": "2.0", "method": "log", "params": { "msg": "warming up" } }

A response carries the same id back, and holds either a result or an error, never both:

{ "jsonrpc": "2.0", "id": 1, "result": 5 }
{ "jsonrpc": "2.0", "id": 2, "error": { "code": -32601, "message": "no such method" } }

That is the entire grammar. The jsonrpc member is always the string "2.0". The id is a string or a number that the sender picks and the responder echoes, so a reply can be matched to its request even when several are in flight. What is striking is that the shape of a message is never stated outright. It is inferred from which fields are present.

message
id
method
result
error
request
·
·
notification
·
·
·
result response
·
·
error response
·
·
● present · absent. The id column separates a request from a notification; the result/error columns separate the two responses. params is optional on the top two and omitted here.

Read the matrix as a decision procedure, which is exactly how the code below will read it. Has a method but no id? Notification. Has both? Request. No method, but an error? Error response. None of those? Result response. The flowchart at the top of this post is the same logic drawn out.

§One Struct, Three Shapes

Because the shape is decided by presence, a single Go struct can model all three, as long as the encoder can tell “absent” from “zero”. The trick is json.RawMessage with omitempty: a nil RawMessage is omitted from the output entirely, which is how a notification ends up with no id member at all rather than "id": null.

package jsonrpc

import (
	"bufio"
	"encoding/json"
	"fmt"
	"io"
)

const Version = "2.0"

// Message is one JSON-RPC 2.0 message. A single struct covers all three shapes,
// because which fields are present is what decides the shape:
//
//	request       jsonrpc + id + method (+ params)
//	notification  jsonrpc + method (+ params)   -- no id
//	response      jsonrpc + id + (result XOR error)
type Message struct {
	JSONRPC string          `json:"jsonrpc"`
	ID      json.RawMessage `json:"id,omitempty"`     // absent => notification
	Method  string          `json:"method,omitempty"` // present => request/notification
	Params  json.RawMessage `json:"params,omitempty"`
	Result  json.RawMessage `json:"result,omitempty"` // present => success response
	Error   *Error          `json:"error,omitempty"`  // present => error response
}

The classifier is the matrix, in code:

// Kind reports which of the three shapes a message is, from its fields alone.
func (m *Message) Kind() string {
	switch {
	case m.Method != "" && len(m.ID) > 0:
		return "request"
	case m.Method != "":
		return "notification"
	case m.Error != nil:
		return "error response"
	default:
		return "result response"
	}
}

A few constructors keep the call sites clean. Reply and ReplyError copy the request’s id straight across, which is the rule that lets a caller match a response to what it asked:

func NewRequest(id int, method string, params any) *Message {
	return &Message{JSONRPC: Version, ID: mustJSON(id), Method: method, Params: mustJSON(params)}
}

// NewNotification builds a notification: a request with no id, so no reply.
func NewNotification(method string, params any) *Message {
	return &Message{JSONRPC: Version, Method: method, Params: mustJSON(params)}
}

func (m *Message) Reply(result any) *Message {
	return &Message{JSONRPC: Version, ID: m.ID, Result: mustJSON(result)}
}

func (m *Message) ReplyError(code int, msg string) *Message {
	return &Message{JSONRPC: Version, ID: m.ID, Error: &Error{Code: code, Message: msg}}
}

§The Error Object and Its Codes

An error response holds an object with an integer code, a short message, and optional data. JSON-RPC 2.0 reserves a handful of codes, and they are worth knowing because the SDK returns them and a hand-written client has to recognize them:

type Error struct {
	Code    int             `json:"code"`
	Message string          `json:"message"`
	Data    json.RawMessage `json:"data,omitempty"`
}

func (e *Error) Error() string { return fmt.Sprintf("jsonrpc %d: %s", e.Code, e.Message) }

// Standard error codes from the JSON-RPC 2.0 specification.
const (
	ParseError     = -32700 // invalid JSON was received
	InvalidRequest = -32600 // the JSON is not a valid request object
	MethodNotFound = -32601 // the method does not exist
	InvalidParams  = -32602 // invalid method parameters
	InternalError  = -32603 // internal JSON-RPC error
	// -32000 to -32099 are reserved for implementation-defined server errors.
)

One rule the type system cannot enforce but every implementation must: a response carries result or error, exactly one. There is a subtlety here that matters later in the series. A -32601 “method not found” is a protocol error, the request never reached a handler. A tool that runs and fails, say a file read that hits a missing file, is not a protocol error. MCP reports that as a normal tool result with an isError flag, so the model can read the failure and try something else. The tools post draws that line in detail. For now: protocol errors live in this error object, application failures do not.

§A Reader and a Writer

JSON-RPC says nothing about how bytes move. That is the transport’s job, and it is the subject of two later posts. But the framing MCP uses on stdio is simple enough to write now and reuse then: one JSON object per line, newline-delimited, no embedded newlines. A bufio.Scanner reads lines, json.Marshal writes them.

// Conn reads and writes newline-delimited JSON-RPC messages, the framing MCP
// uses on stdio: one JSON object per line, no embedded newlines.
type Conn struct {
	in  *bufio.Scanner
	out io.Writer
}

func NewConn(r io.Reader, w io.Writer) *Conn {
	sc := bufio.NewScanner(r)
	sc.Buffer(make([]byte, 0, 64*1024), 16*1024*1024) // allow large messages
	return &Conn{in: sc, out: w}
}

func (c *Conn) Read() (*Message, error) {
	if !c.in.Scan() {
		if err := c.in.Err(); err != nil {
			return nil, err
		}
		return nil, io.EOF
	}
	var m Message
	if err := json.Unmarshal(c.in.Bytes(), &m); err != nil {
		return nil, &Error{Code: ParseError, Message: err.Error()}
	}
	return &m, nil
}

func (c *Conn) Write(m *Message) error {
	m.JSONRPC = Version
	b, err := json.Marshal(m)
	if err != nil {
		return err
	}
	_, err = c.out.Write(append(b, '\n'))
	return err
}

The default bufio.Scanner buffer caps lines at 64 KB, and real MCP messages, a base64 image in a tool result for instance, blow past that fast, so the buffer is raised. That single line is the kind of thing you only learn by reading the wire and watching a large message vanish. The mustJSON helper is a two-line json.Marshal wrapper, omitted here for space.

§Run It

A server is now just a loop: read a message, classify it, dispatch by method, and reply unless it was a notification. Here is one with two methods, add and a log notification handler, fed three lines: a request, a notification, and a request for a method that does not exist.

conn := jsonrpc.NewConn(strings.NewReader(input), os.Stdout)
for {
	msg, err := conn.Read()
	if err == io.EOF {
		break
	}
	h, ok := handlers[msg.Method]
	if !ok {
		if msg.Kind() == "notification" {
			continue // a notification gets no reply, even on error
		}
		conn.Write(msg.ReplyError(jsonrpc.MethodNotFound, "no such method: "+msg.Method))
		continue
	}
	result, rpcErr := h(msg.Params)
	if msg.Kind() == "notification" {
		continue
	}
	if rpcErr != nil {
		conn.Write(msg.ReplyError(rpcErr.Code, rpcErr.Message))
		continue
	}
	conn.Write(msg.Reply(result))
}

Three messages go in. Here is the real run, server-side classification on stderr, response frames on stdout:

in  [request] method="add"
{"jsonrpc":"2.0","id":1,"result":5}
in  [notification] method="log"
  (server logged a notification: {"msg":"warming up"})
in  [request] method="divide"
{"jsonrpc":"2.0","id":2,"error":{"code":-32601,"message":"no such method: divide"}}

Three in, two out. The notification was handled and produced no response, exactly as a missing id demands. add matched and returned 5 under its id. divide does not exist, so it came back as a -32601 error under its id. That is the entire grammar working, in code that imports nothing but the standard library. The SDK does a great deal more, schema validation, capability tracking, the transports, but at the bottom it is reading and writing exactly these shapes.

§What MCP Adds, and What It Removes

MCP is JSON-RPC 2.0 plus a vocabulary of method names (initialize, tools/list, and the rest) and a lifecycle that governs when each is legal. It also takes one thing away. Base JSON-RPC 2.0 allows a batch: a JSON array of several messages sent together. MCP removed batching in revision 2025-06-18. The changelog lists it first: “Remove support for JSON-RPC batching.” So a conforming MCP implementation sends one message per frame, which is why the line-delimited reader above is enough and an array-handling branch is not. If you ship against an older revision, that is a real difference to keep straight.

Two more details the run above quietly depends on. An id may be a string or a number, and for a request it must not be null, because null is reserved for a response to a request whose id could not be parsed. And ids are scoped per sender: in the last post the server’s own sampling/createMessage request carried id:1 while the client’s tools/call was id:2, two independent counters sharing one connection. The Reply helper above never invents an id, it only echoes one, which is what keeps those two streams from colliding.

§Why Build This When the SDK Has It

You will not ship this package. You will use the SDK. The point is that you now own the floor it stands on. When a frame looks wrong in a log, you can name its shape on sight and say whether the id should be there. When the next two posts build a stdio transport and a Streamable HTTP transport by hand, this exact jsonrpc package is what they move bytes for, so the transport code can be about pipes and sessions instead of re-deriving what a message is.

txn2/mcp-data-platform is built on this grammar, and its audit log records every JSON-RPC tool call mapped to the user who made it. When a frame looks wrong in that log, naming its shape, request or notification, result or error, is the first step to explaining it.

§What’s Next

The grammar is in hand. The next post, The Handshake: initialize, Capabilities, and Version Negotiation, spends itself on the single most important exchange in any MCP session, the one that has to happen before any other method is legal. It builds on this jsonrpc package, sending a real initialize and reading back the capabilities the server is willing to commit to, and shows why the server in the first post refused to list its tools until the handshake was done.


The production data platform behind this series is txn2/mcp-data-platform, available hosted as Plexara.

← back to all notes