Module 6 · The Model Context Protocol (MCP) ⏱ 18 min

JSON-RPC: The Wire Format Under MCP

By the end of this lesson you will be able to:
  • Recognise that every MCP message is a JSON-RPC 2.0 message
  • Build a well-formed request, response, and notification by hand
  • Apply the rule that a response carries exactly one of result or error
  • Use the standard JSON-RPC error codes and explain what each signals

MCP did not invent a wire format — it adopted one. Underneath every tool listing, every capability negotiation, and every tool call is a plain JSON-RPC 2.0 message: a small JSON object with a fixed set of fields. Learn that one shape and you can read every byte an MCP client and server ever exchange.

JSON-RPC is worth knowing on its own terms because it is deliberately boring. There is no streaming magic, no schema engine, no transport — just a request, a response, and a few rules about which fields go where. That simplicity is the point: a protocol you can implement in an afternoon is a protocol that gets implemented correctly. This lesson builds the three message kinds and the error model by hand, with no library.

flowchart LR
  C["client"] -->|"request: id 1, method ping"| S["server"]
  S -->|"response: id 1, result pong"| C
  style C fill:#3776ab,color:#fff
  style S fill:#8b5cf6,color:#fff
A request and its response share the same id, so the caller can pair them up.

The request

Every message starts with "jsonrpc": "2.0" — a version tag so old and new formats can coexist. A request then carries three things: an id that names this call, a method string naming what to do, and an optional params object holding the arguments. The id is the crucial part: it is the handle the caller uses to match the eventual reply to this specific call, so every request that expects an answer must have one.

Params are optional because some methods take no arguments — a ping needs nothing. When params do appear they are usually an object, so add with arguments a and b sends params: { a: 2, b: 3 }. The method name is a dotted string like tools/list or tools/call; the dots are just naming, nothing special happens at the dot.

Build a JSON-RPC request and serialise it to the wire. Press Run.
function makeRequest(id, method, params) {
  const req = { jsonrpc: '2.0', id: id, method: method };
  if (params !== undefined) req.params = params;
  return req;
}

console.log(JSON.stringify(makeRequest(1, 'ping')));
console.log(JSON.stringify(makeRequest(2, 'add', { a: 2, b: 3 })));

The response: result or error, never both

A response echoes the request's id back exactly, then carries precisely one of two fields: result on success, or error on failure. That is the load-bearing rule of the whole protocol — a response holds result or error, never both and never neither. The matching id is what lets a caller with ten outstanding requests know which reply answers which call.

A notification is the third message kind, and it is defined by what it lacks: an id. Because it has no id, the sender expects no response — fire and forget. MCP uses notifications for one-way signals like notifications/initialized, where there is nothing to reply with and waiting for an answer would hang forever.

flowchart TD
  A["request"] --> B["has id and method<br/>expects a response"]
  C["response"] --> D["same id, one of result or error"]
  E["notification"] --> F["no id, no response"]
  style A fill:#3776ab,color:#fff
  style C fill:#166534,color:#fff
  style E fill:#b45309,color:#fff
Three message kinds: a request wants a reply, a response carries one, a notification wants none.
A success response and an error response. Both echo the id; each carries one field. Run it.
function makeResponse(id, result) {
  return { jsonrpc: '2.0', id: id, result: result };
}
function makeError(id, code, message) {
  return { jsonrpc: '2.0', id: id, error: { code: code, message: message } };
}

console.log(JSON.stringify(makeResponse(1, 'pong')));
console.log(JSON.stringify(makeError(1, -32601, 'Method not found')));
flowchart LR
  R["error response"] --> O["error object"]
  O --> P["code: a standard number"]
  O --> Q["message: human-readable text"]
  O -->|"for example"| X["-32601 method not found"]
  style O fill:#b91c1c,color:#fff
An error response holds an error object with a numeric code and a human-readable message.
A notification has no id. The standard error codes are fixed numbers. Run it.
// A notification: no id, so it expects no reply.
const notify = { jsonrpc: '2.0', method: 'progress', params: { done: 50 } };
console.log('id' in notify ? 'request' : 'notification');

// The standard JSON-RPC error codes are reserved numbers.
const CODES = {
  '-32700': 'Parse error',
  '-32600': 'Invalid request',
  '-32601': 'Method not found',
  '-32602': 'Invalid params'
};
console.log(CODES['-32601']);

Reading the error codes

The four codes you meet most often are negative numbers in the -32xxx band, and JSON-RPC reserves that band on purpose so your own application errors never collide with the protocol's. -32700 means the incoming bytes were not even valid JSON, so the message could not be parsed at all. -32600 means the JSON was fine but the message was not a legal request, say a response shape sent where a request belonged. -32601, the one you see first when a server does not yet implement a method, means method not found. -32602 flags arguments that arrived but were wrong for the method. Reading the code first tells you where to look before you read a word of the message text.

Exercise

What does this print? An object's JSON form lists its keys in the order they were written. There is one console.log.

function makeRequest(id, method) {
  return { jsonrpc: '2.0', id: id, method: method };
}
console.log(JSON.stringify(makeRequest(1, 'ping')));
Exercise

Write makeResponse(id, result) that returns a JSON-RPC success response: the version tag, the echoed id, and a result field set to result. No error field.

function makeResponse(id, result) {
  // jsonrpc 2.0, the id, and result only
}
Exercise

A JSON-RPC response carries exactly ONE of result or error — never both. This respond always attaches both fields. Fix it so a successful call (when error is falsy) returns only result, and a failed call (when error is truthy) returns only error.

function respond(id, result, error) {
  return { jsonrpc: '2.0', id: id, result: result, error: error };
}
Exercise

Write isValidResponse(msg) that returns true only when msg is a legal JSON-RPC response: it has jsonrpc equal to '2.0', it has an id, and it carries exactly one of result or error (not both, not neither). This 'exactly one of' check is the protocol's core invariant.

function isValidResponse(msg) {
  // jsonrpc 2.0, has id, and exactly one of result or error
}
Exercise

What distinguishes a JSON-RPC notification from a request?

Recap

  • Every MCP message is JSON-RPC 2.0: a JSON object beginning with "jsonrpc": "2.0".
  • A request has id, method, and optional params; the id lets the caller match the reply.
  • A response echoes the id and carries exactly one of result (success) or error (failure) — never both.
  • A notification has no id and gets no response; MCP uses it for one-way signals.
  • Errors are an object { code, message } with standard codes: -32700 parse, -32600 invalid request, -32601 method not found, -32602 invalid params.

Next you will put these messages to work in the MCP handshake, where client and server declare their version and capabilities before any real request flows.

Checkpoint quiz

Why must a JSON-RPC response echo the request's id?

Which error code does JSON-RPC reserve for 'method not found'?

Go deeper — technical resources