Module 02

JSON-RPC 2.0 & The Message Layer

Building Systems Model Context Protocol (MCP)

Module 2 — JSON-RPC 2.0 & The Message Layer

Phase 1 · Foundations. Module 1 was the who and why. This is the what exactly travels on the wire. By the end you can read raw MCP traffic and hand-write every message type from memory. This sounds dry; it’s the opposite — once you see the bytes, MCP stops being magic and becomes obvious, and you’ll debug ten times faster because you can read the Inspector output like a sentence.

By the end you can:

  1. Write the four JSON-RPC message shapes from scratch: request, success response, error response, notification.
  2. Explain request id semantics and why notifications have none.
  3. Distinguish a protocol error from a tool-execution error — and explain why MCP deliberately keeps them in different layers.
  4. Read a real initializetools/listtools/call exchange and know what each field does.

1. Why JSON-RPC 2.0?

MCP doesn’t invent a message format. It uses JSON-RPC 2.0, a tiny, mature, transport-independent standard for “call a method on the other side and (maybe) get a result back.” This is a deliberate, boring choice — and boring is good for a protocol. JSON-RPC gives MCP:

  • A request/response correlation model (via id) so many calls can be in flight at once.
  • A notification model (no id) for fire-and-forget events.
  • A uniform error envelope so failures look the same everywhere.
  • Transport independence — the same JSON works over stdio, over HTTP, over anything that moves bytes. (That’s why Module 3’s transports are interchangeable.)

Every MCP interaction — the handshake, listing tools, calling a tool, progress updates, cancellation — is just JSON-RPC messages with MCP-defined method names and parameter shapes. Learn the envelope once and the rest is vocabulary.

The two invariants in every message:

  • "jsonrpc": "2.0" — always present, always exactly this string.
  • Either an id (for requests/responses) or no id (for notifications).

2. The four message shapes

(a) Request — “do this and reply”

A request expects a response. It must carry an id (a string or number, unique within the session for the sender) and a method, plus optional params.

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "read_file",
    "arguments": { "path": "notes.txt" }
  }
}
  • id — the correlation handle. The reply will echo this exact id so the sender can match response to request even with many outstanding. Don’t reuse an id while its response is still pending.
  • method — what to do. MCP method names are namespaced with /: tools/list, tools/call, resources/read, prompts/get, initialize, ping.
  • params — a structured object. Its shape is defined per method by the MCP spec.

(b) Success response — “here’s your result”

Echoes the request’s id and carries a result object (method-defined shape). A response has exactly one of result or error, never both, never neither.

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "content": [
      { "type": "text", "text": "remember to buy milk" }
    ],
    "isError": false
  }
}

(c) Error response — “that request itself was bad”

Also echoes the id, but carries an error object instead of result. This is for protocol-level failures (see §4 — it is not how a tool reports that its work failed).

{
  "jsonrpc": "2.0",
  "id": 1,
  "error": {
    "code": -32601,
    "message": "Method not found",
    "data": { "method": "tools/cal" }
  }
}
  • code — an integer. JSON-RPC reserves a standard set (table in §3); MCP/apps may define others outside the reserved range.
  • message — short human-readable summary.
  • data — optional, any extra detail.

(d) Notification — “FYI, no reply wanted”

A notification is a request with no id. The receiver must not reply. Used for events and one-way signals: progress, cancellation, “my list changed,” and the initialized signal. MCP notification methods live under notifications/.

{
  "jsonrpc": "2.0",
  "method": "notifications/progress",
  "params": {
    "progressToken": "abc-123",
    "progress": 42,
    "total": 100
  }
}

The presence or absence of id is the entire distinction between “I expect an answer” and “I’m just telling you.” That’s the whole model.

Batching (know it exists): JSON-RPC allows sending an array of messages as one batch. MCP’s handling of batching has shifted across spec versions — some versions require support, the 2025-03-26 line moved away from requiring it. Don’t rely on batching for core flows; treat it as an optimization and check the version you target. The single-message shapes above are what you’ll use 99% of the time.


3. Request id semantics and standard error codes

id rules that actually matter:

  • A request id must be unique within the session from the sender’s perspective while the request is unanswered.
  • The response must echo the same id. This is how concurrency works — five tools/calls can be in flight and each reply finds its home by id.
  • id may be a number or a string. Numbers are convenient; strings (e.g. UUIDs) are common in generated code. JSON-RPC technically forbids null as an id and forbids fractional weirdness — just use clean integers or UUID strings.
  • Notifications have no id and get no response — ever. Sending an “id-less request” is sending a notification.

Standard JSON-RPC error codes (memorize the top ones — they appear constantly in debugging):

CodeMeaningTypical cause
-32700Parse errorInvalid JSON was received
-32600Invalid RequestNot a valid JSON-RPC object
-32601Method not foundUnknown/typo’d method (e.g. tools/cal)
-32602Invalid paramsWrong/missing arguments for the method
-32603Internal errorGeneric server-side failure
-32000 to -32099Server error (reserved range)Implementation-defined protocol errors

Application errors should use codes outside the reserved -32768…-32000 range. But — and this is the pivot to the next section — most tool failures shouldn’t be JSON-RPC errors at all.


4. The critical distinction: protocol error vs tool-execution error

This is the concept in Module 2 that separates people who get MCP from people who fight it. There are two different layers of failure and they are reported in two different ways.

Layer 1 — Protocol error → use the error response object. The request was structurally wrong or couldn’t be processed as a request: bad JSON, unknown method, invalid params, server exploded before it could run the tool. The transport/protocol machinery failed. → Reply with error and a code from §3.

Layer 2 — Tool-execution error → use a successful response with isError: true. The request was perfectly valid, the tool ran, and the tool’s work failed in a normal domain sense: the file didn’t exist, the API returned 404, the SQL query was rejected, the input was out of range. This is not a protocol failure — the protocol worked flawlessly. → Reply with a normal result whose content describes the failure and whose isError is true.

// Tool ran, but the work failed — this is a SUCCESS at the protocol layer
{
  "jsonrpc": "2.0",
  "id": 7,
  "result": {
    "content": [
      { "type": "text", "text": "Error: file 'missing.txt' not found." }
    ],
    "isError": true
  }
}

Why does MCP split it this way? Because the two failures have different audiences:

  • A protocol error is for the client/host machinery. The model usually shouldn’t “see” it as content — it means something is broken in the plumbing (retry, reconnect, surface a bug).
  • A tool-execution error is for the model. By returning it as ordinary content with isError: true, the model reads “the file wasn’t found” and can react intelligently — apologize, try a different path, ask the user. If tool failures were thrown as protocol errors, the model would never see them and couldn’t recover; the host would just get an exception.

Security angle (you’ll revisit this in Phase 3): keep the layers clean so you can log and reason about them separately. “Caller wasn’t allowed to call this tool” is arguably a protocol/authorization concern enforced before execution; “the tool ran and the upstream said no” is a domain result. Mixing them muddies audit trails and makes least-privilege enforcement harder.

The one-line rule: Did the request reach and run the tool? No → error. Yes, but the work failed → result with isError: true.


5. A complete exchange, annotated

Here is a minimal real session: handshake, discover tools, call one. (Lifecycle details are Module 3 — here just notice the messages.)

// 1. Client → Server: open the session
{ "jsonrpc": "2.0", "id": 1, "method": "initialize",
  "params": {
    "protocolVersion": "2025-11-25",
    "capabilities": { "sampling": {}, "roots": { "listChanged": true } },
    "clientInfo": { "name": "demo-host", "version": "1.0.0" }
  } }

// 2. Server → Client: agree on version, advertise capabilities
{ "jsonrpc": "2.0", "id": 1,
  "result": {
    "protocolVersion": "2025-11-25",
    "capabilities": { "tools": { "listChanged": true }, "resources": {} },
    "serverInfo": { "name": "files-server", "version": "0.3.1" }
  } }

// 3. Client → Server: handshake done (NOTIFICATION — no id, no reply)
{ "jsonrpc": "2.0", "method": "notifications/initialized" }

// 4. Client → Server: what tools do you have?
{ "jsonrpc": "2.0", "id": 2, "method": "tools/list" }

// 5. Server → Client: here they are (note the JSON Schema)
{ "jsonrpc": "2.0", "id": 2,
  "result": {
    "tools": [
      { "name": "read_file",
        "description": "Read a UTF-8 text file by relative path.",
        "inputSchema": {
          "type": "object",
          "properties": { "path": { "type": "string" } },
          "required": ["path"]
        } }
    ] } }

// 6. Client → Server: call it
{ "jsonrpc": "2.0", "id": 3, "method": "tools/call",
  "params": { "name": "read_file", "arguments": { "path": "notes.txt" } } }

// 7. Server → Client: result (success at both layers)
{ "jsonrpc": "2.0", "id": 3,
  "result": {
    "content": [ { "type": "text", "text": "remember to buy milk" } ],
    "isError": false
  } }

Read it twice. Every single MCP interaction you will ever debug is a variation on this. Things to notice:

  • The initialize response echoes id 1; tools/list is id 2; tools/call is id 3 — each reply matches by id.
  • Step 3 is a notification — no id, no response comes back.
  • The tool is described by a JSON Schema inputSchema. The model uses this to construct valid arguments. (Schema quality = tool-call reliability — that’s Module 4.)
  • The result content is an array of typed blocks (type: "text" here; images, audio, embedded resources, and structured content are also possible).

6. Milestone exercise

By hand, on paper or in a scratch file, write valid JSON-RPC for each — no copying from §5:

  1. An initialize request from a client that supports sampling and proposes protocol version 2025-11-25.
  2. A tools/list request.
  3. A tools/call request for a tool named create_issue with arguments { "title": "Bug", "priority": "high" }.
  4. A tool result that signals a domain error — the issue tracker rejected the call because the project is archived. (Think hard: which layer? what flag?)
  5. A protocol error response for a client that called a method named tool/call (note the typo). Which code?

Self-check:

  • #1 includes "jsonrpc": "2.0", an id, method: "initialize", and params with protocolVersion + capabilities.
  • #2 has an id and needs no params.
  • #3 nests the tool name and args under params (name + arguments), not at the top level.
  • #4 is a result (not error) with isError: true and a human-readable message in content — because the tool ran and the work was rejected.
  • #5 is an error response with code -32601 (Method not found) — because tool/call isn’t a real method.
  • You never put both result and error in one response, and your notification (if any) has no id.

Nail #4 vs #5 and you’ve internalized the layer split that confuses most people. Then go to Module 3, where these same messages get wrapped in a lifecycle and pushed over real transports.


Quick reference — Module 2 glossary

  • JSON-RPC 2.0 — the transport-independent message standard MCP is built on.
  • Request — has id + method (+ params); expects a response.
  • Response — echoes id; carries exactly one of result or error.
  • Notification — no id; one-way; never answered (e.g. notifications/initialized, notifications/progress).
  • id — correlation handle that matches a response to its request; enables concurrency.
  • Protocol errorerror object + standard code (-32601 etc.); the request itself failed.
  • Tool-execution error — a successful result with isError: true; the tool ran but its work failed. The model is meant to read and recover from these.