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
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.
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
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
// 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.
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')));
JSON.stringify keeps the keys in the order the object literal wrote them.
There are no spaces in the default JSON output, so it is one compact line.
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
}
Return an object literal with jsonrpc, id, and result in that order.
JSON.stringify produces no spaces, so match the compact form exactly.
function makeResponse(id, result) {
return { jsonrpc: '2.0', id: id, result: result };
}
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 };
}
Start the object with just jsonrpc and id, then branch on whether error is truthy.
On the success branch set only result; on the failure branch set only error.
function respond(id, result, error) {
const msg = { jsonrpc: '2.0', id: id };
if (error) {
msg.error = error;
} else {
msg.result = result;
}
return msg;
}
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
}
Guard null first, then check the version string and that id is present.
Use the inequality of the two membership tests: ('result' in msg) !== ('error' in msg) is true only when exactly one is present.
function isValidResponse(msg) {
return msg != null
&& msg.jsonrpc === '2.0'
&& 'id' in msg
&& ('result' in msg) !== ('error' in msg);
}
What distinguishes a JSON-RPC notification from a request?
A notification is defined by the absence of an id. With no id, the sender cannot match a reply, so none is expected — which is exactly why MCP uses notifications for one-way signals like initialized.
Recap
- Every MCP message is JSON-RPC 2.0: a JSON object beginning with
"jsonrpc": "2.0". - A request has
id,method, and optionalparams; theidlets the caller match the reply. - A response echoes the
idand carries exactly one ofresult(success) orerror(failure) — never both. - A notification has no
idand gets no response; MCP uses it for one-way signals. - Errors are an object
{ code, message }with standard codes:-32700parse,-32600invalid request,-32601method not found,-32602invalid 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.