MCP does not invent a wire format. It borrows JSON-RPC 2.0 — a deliberately boring, long-settled remote-procedure-call convention — and layers a defined method namespace, a lifecycle, and a set of schemas on top of it. That choice is why an MCP session is legible: every byte crossing the connection is one of exactly three things, and you can read an entire conversation in a text editor. This article is about that wire. What a request, a response, and a notification actually look like; how IDs correlate a reply to its call and why notifications deliberately have none; what belongs in the envelope versus the payload; how errors are shaped and which numeric ranges are already spoken for; what to assume about batching; and the whole lifecycle read as a message trace. The last part is the one that surprises people: in MCP the arrows point both ways.

JSON-RPC 2.0 — the substrate MCP chose

JSON-RPC 2.0 is about as small as a protocol gets. It says: messages are JSON objects, each carries "jsonrpc": "2.0", a call names a method and optionally supplies params, and a reply carries either result or error — never both. It says nothing about transport, authentication, streaming, or what the methods are. That deliberate emptiness is exactly what made it attractive: MCP got framing, correlation, and error conventions for free, and every language already has a parser.

What MCP adds is everything the base spec leaves open. It fixes a method vocabulary (tools, resources, prompts, sampling, roots, logging), defines JSON schemas for every params and result object, mandates an opening handshake before any other traffic, and tightens a few of JSON-RPC's permissive corners — most visibly around request IDs. The useful mental model: JSON-RPC is the grammar, MCP is the language.

Advertisement

Three message kinds — request, response, notification

Everything on an MCP connection is one of three shapes, and being able to classify a line at a glance is most of what protocol debugging is.

A request carries an id, a method, and usually params. It obliges the receiver to eventually answer. A response carries the same id and exactly one of result or error; it never carries a method, because the id already says which call it belongs to. A notification looks like a request with the id surgically removed: a method and params, no reply expected, no reply permitted. That last rule is strict — answering a notification is a protocol violation, not a harmless extra.

MCP message typesRequestid + method + paramsResponseid + result / errorNotificationsid-less, one-wayBidirectional: both client and server can send requests; supports server-initiated events
JSON-RPC in MCP.

IDs, correlation, and why notifications have none

The id exists for one reason: a connection is a duplex pipe, not a queue of turn-taking pairs. A client can have ten calls outstanding, and responses may come back in any order — a fast tools/list can easily overtake a slow tools/call. The id is what lets the sender match the reply to the promise it is holding open.

Base JSON-RPC permits a string, a number, or null as the identifier. MCP tightens this: an id must not be null, and it must not be reused for a second request while the first is still outstanding on that session. Notifications have no id because there is nothing to correlate — they are fire-and-forget events such as notifications/progress, notifications/cancelled, or a list-changed signal. The absence of the field is the type marker, so a receiver classifies a message by checking whether id is present at all.

The envelope and the method namespace

The envelope is thin. Every message carries the version tag; calls and notifications carry a method; everything else is nested inside params. The parts that matter to routing look like this:

{ "jsonrpc": "2.0", "id": 7,
  "method": "tools/call",
  "params": { "name": "search", "arguments": { "q": "mcp" } } }

Method names use a slash-delimited namespace, and that namespace is the protocol's table of contents: tools/list and tools/call, resources/list and resources/read, prompts/list and prompts/get, plus initialize and ping at the top level. Notifications live under their own prefix — notifications/initialized, notifications/message — so the shape and the name agree. Extension metadata rides in a reserved _meta field rather than as invented top-level keys, which keeps unknown-field handling predictable.

Result shapes — payloads and content blocks

A successful reply puts everything under result. For listing methods that is a plain object: an array of tool or resource descriptors and, when the collection is long, a cursor for the next page (pagination is its own article).

The more interesting shape is the one used where output is destined for a model. Rather than a bare string, these results carry an ordered array of content blocks, each self-describing by a type: text, image, audio, and resource-flavoured blocks that either embed a resource's contents inline or hand back a pointer the client can subsequently resources/read. The array is the point: one tool call can return prose, then a chart, then a link, each piece keeping its own media type. Current revisions also allow a tool to return machine-readable structured output alongside those blocks, so a caller can parse a result without scraping the text meant for the model.

Error objects and the reserved code ranges

When a call fails at the protocol level, the reply swaps result for an error object with three fields: a numeric code, a short human message, and an optional data payload for structured detail.

The codes are not free-form. JSON-RPC reserves a block for the failures that can happen to any RPC system, and MCP inherits them unchanged:

CodeMeaning
-32700Parse error — the bytes were not valid JSON
-32600Invalid request — JSON, but not a valid message
-32601Method not found — unknown or unsupported method
-32602Invalid params — arguments failed the schema
-32603Internal error — the handler blew up
-32000 to -32099Reserved for implementation-defined errors

Outside those bands you may define your own. One caveat belongs here only as a pointer: a tool that runs correctly but reports a failing outcome does not use an error object — it returns a normal result flagged with isError. That distinction is the subject of the MCP error-handling article.

Advertisement

Batching — an array you should not depend on

Base JSON-RPC lets a sender put several messages in a single array and receive an array of responses back. MCP has had an unsettled relationship with the idea: batching was specified in an earlier protocol revision and removed in a later one, on the grounds that it added real implementation complexity for a benefit that streaming transports already deliver.

The operational rule is therefore simple, and it holds under every revision: treat batched arrays as unavailable. Send one message per frame, and write your parser so that receiving a top-level array is a handled condition rather than a crash — a compliant peer is entitled to reject it outright. If your motivation for batching was throughput, the answer is concurrency rather than framing: because correlation is by id and not by arrival order, you can have many requests in flight at once on a single connection and let the responses land whenever they land.

A message trace — from initialize to teardown

Read a session as a transcript and the protocol stops being abstract. It opens with a mandatory handshake: the client sends initialize, the server replies, and the client follows with the notifications/initialized notification to declare the connection live. What gets negotiated in those two messages — versions and capabilities — is the subject of its own article; here it matters only as a shape.

C -> S  {"id":1,"method":"initialize", ...}
S -> C  {"id":1,"result":{ ... }}
C -> S  {"method":"notifications/initialized"}
C -> S  {"id":2,"method":"tools/list"}
S -> C  {"id":2,"result":{"tools":[ ... ]}}
C -> S  {"id":3,"method":"tools/call", ...}
S -> C  {"method":"notifications/progress", ...}
S -> C  {"id":3,"result":{"content":[ ... ]}}

After the handshake the middle of the trace is unordered: discovery and invocation interleave freely with notifications. And the trace simply stops — there is no shutdown method. Ending a session is a transport concern, handled by closing the pipe or dropping the session, which the transport article covers.

Bidirectionality — the server can call you

Here is the structurally unusual part, and the reason MCP is not just HTTP with extra steps. In most RPC systems the roles are fixed: one side calls, the other answers. In MCP both peers can originate requests. After the handshake, the server may send a request to the client and wait for a reply, using the same envelope, the same ID rules, and its own independent ID space.

That single design decision is what makes server-initiated features possible at all — a server asking the host to run a model completion, a server asking the user a question mid-tool-call, a server asking which workspace directories it is allowed to see. Each of those is a separate article; what belongs here is the consequence for your code. You cannot write an MCP client as a request-response wrapper. Both sides need a real dispatcher: an inbound-request table and a handler registry, not just a map of pending promises. Ignoring inbound requests does not fail loudly — it hangs the peer.

Reading the wire — debugging by message trace

Because the protocol is line-oriented JSON, the fastest debugging tool is a log of raw frames in both directions. Almost every integration bug announces itself clearly in that transcript.

SymptomUsual cause
Peer hangs foreverAn inbound request nobody dispatched, or a response with the wrong id
Spurious −32600 repliesA missing jsonrpc field, or JSON that is not a valid message
Wrong result for a callAn id reused while still outstanding
−32601 on a known methodTraffic sent before the handshake completed

Two habits pay for themselves. Log frames verbatim before parsing, so malformed input is visible rather than swallowed by a JSON exception. And assert the invariants in your own transport layer — one of result or error, never both; no reply without a matching outstanding id; no id on a notification. They fit in a few lines and convert silent hangs into named failures.

MCP's wire format is JSON-RPC 2.0 plus a fixed method namespace and schemas. Everything on the connection is a request (id + method + params), a response (same id, exactly one of result or error), or a notification (method + params, no id, no reply) — and the presence or absence of id is how you tell them apart. IDs exist to correlate replies on a duplex pipe, so requests can be concurrent and out-of-order; MCP forbids null and in-flight reuse. Errors use the reserved JSON-RPC code bands; batched arrays should be treated as unavailable and concurrency used instead. The lifecycle is a handshake, then free-form traffic, then a transport-level close — there is no shutdown method. The detail that changes your architecture is bidirectionality: the server can send requests to you, so both peers need a dispatcher, not a client.