Every message an MCP client and server exchange — a tool call, a handshake, a ping, a progress update — is carried inside the same small envelope: JSON-RPC 2.0. MCP doesn’t invent its own wire format. It borrows a spec that’s older than the protocol itself, and almost everything that looks like an MCP-specific rule — how a request pairs with its response, what a notification is, how errors are shaped — is actually a JSON-RPC rule MCP just inherits.

The three shapes a message can take

JSON-RPC 2.0 defines exactly three kinds of message, and MCP uses all three without changing their structure:

  • A request — has a method, optional params, and an id. The id is what makes it a request instead of a notification: whoever sent it expects exactly one response carrying that same id back. tools/call, initialize, and resources/read are all requests.
  • A response — carries the same id as the request it answers, plus either a result or an error, never both. There’s no in-between state in the message itself; a response is either a success or a failure, and the receiving side doesn’t have to inspect the payload to find out which.
  • A notification — a method and optional params, with no id at all. No id means no reply is coming, ever. notifications/cancelled, notifications/progress, and notifications/resources/updated are all notifications — the naming convention itself is a hint, though it’s the missing id that actually defines the category.

That last distinction explains a few things that otherwise look like missing features. Cancellation is best-effort partly because it’s a notification: there’s no response slot for the server to confirm it actually stopped anything, because a notification was never going to get one.

What a request actually contains

A JSON-RPC request in MCP looks close to this, trimmed down:

{
  "jsonrpc": "2.0",
  "id": 7,
  "method": "tools/call",
  "params": {
    "name": "search_docs",
    "arguments": { "query": "refund policy" }
  }
}

jsonrpc is always the literal string "2.0" — it’s a version marker for the envelope format, not for MCP itself. id can be a number or a string; the only rule is that whoever assigns it has to pick something unique among their own in-flight requests, since it’s the only thing tying a response back to the call that triggered it. method names the operation as a flat string — MCP’s convention of category/action, like tools/call or resources/read, is an MCP naming choice layered on top of JSON-RPC, not something the base spec requires. params is whatever shape that specific method defines; JSON-RPC doesn’t constrain it beyond requiring an object or array.

A success response echoes the id and swaps method/params for result:

{
  "jsonrpc": "2.0",
  "id": 7,
  "result": {
    "content": [{ "type": "text", "text": "Refunds are processed within 5 business days." }]
  }
}

That result shape — a content array, optionally structuredContent and isError — is MCP’s own schema for a tool call result, covered in more depth in how MCP structured output works and how MCP tool error handling works. JSON-RPC doesn’t know or care what’s inside result; it only guarantees the envelope around it.

Two kinds of failure, not one

This is the part of JSON-RPC that trips people up most, because MCP has two separate failure channels that both end up looking like “the tool call failed” from a distance:

  • A JSON-RPC error response means the request itself couldn’t be processed — a malformed call, an unknown method, a method that exists but choked on bad input before it ever reached the tool’s own logic. The error object carries a numeric code, a short message, and optional data. A handful of codes are reserved by the JSON-RPC spec itself — -32700 for a parse error, -32601 for method not found, -32602 for invalid params — and MCP can define its own codes above that reserved range for protocol-specific failures.
  • A normal result with isError: true means the request was processed fine at the protocol level, and the tool itself reported that its own operation failed — a search that found nothing, an API call that got a 404, a validation check that rejected the input. This is the channel MCP tool error handling covers, and it’s deliberately not a JSON-RPC error, because the call itself succeeded; it’s the outcome that was bad.

The practical difference: a JSON-RPC error is something a client’s transport or routing layer typically handles, often without ever showing the model anything useful. An isError result flows into the model’s context like any other tool output, which is exactly why a tool author writing that message is writing for the model, not for a log file.

Where this surfaces in practice

Day to day, nothing about JSON-RPC is visible through a chat client — Claude or Cursor builds the envelope and reads it back for you. It becomes visible the moment something breaks and you go looking at raw traffic with a tool like the MCP Inspector: an id that never comes back paired with a response means a request is hanging, not failing outright. A response with no matching id on your side means a client bug, not a server bug. And a server that occasionally sends a bare object with neither result nor error for a given id is simply not spec-compliant, independent of whatever the tool itself was trying to do.

It also explains why MCP works the same way whether a server runs over stdio on your machine or over Streamable HTTP as a remote server. JSON-RPC doesn’t care what carries the bytes. The message shapes, the request/response pairing by id, the notification semantics — all of it is identical whichever transport is underneath, which is exactly why MCP could pick it up wholesale instead of designing a new format.

If you build a server

Most MCP SDKs handle the JSON-RPC envelope for you, so you’re rarely constructing these objects by hand. Where it still matters is distinguishing your two failure channels on purpose: reach for a JSON-RPC-level error only for things genuinely wrong with the request shape itself, and use isError: true for anything your tool’s own logic decided didn’t succeed. Collapsing both into one undifferentiated “something went wrong” response makes a model’s retry behavior worse in both directions — it may retry a malformed call that will never succeed, or give up on a transient failure it could have recovered from with better information. If you’re generating a server from an existing API with a tool like gate’s MCP server builder, this envelope is handled by the SDK underneath, not something your OpenAPI spec needs to describe.

Where gate fits: JSON-RPC tells you a message was well-formed; it says nothing about whether a tool’s description or arguments are trying to do something you didn’t intend. Every call that passes through a gateway lands in a plain-language log regardless of which failure channel it used, and gate’s free MCP security scanner checks the content a server sends — tool names, descriptions, schemas — before you ever connect, which is a layer above anything the envelope format can see.

The bottom line

MCP’s messages are JSON-RPC 2.0 requests, responses, and notifications, full stop — an id means a reply is expected, its absence means it isn’t, and a response is either a result or an error but never both. Knowing that envelope is what separates “this tool call failed” from the two genuinely different things that phrase can mean: a malformed request the protocol rejected, or a request the tool processed and reported back as unsuccessful. See gate’s MCP server directory for the servers this series has covered in more depth.