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.
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.
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:
| Code | Meaning |
|---|---|
| -32700 | Parse error — the bytes were not valid JSON |
| -32600 | Invalid request — JSON, but not a valid message |
| -32601 | Method not found — unknown or unsupported method |
| -32602 | Invalid params — arguments failed the schema |
| -32603 | Internal error — the handler blew up |
| -32000 to -32099 | Reserved 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.
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.
| Symptom | Usual cause |
|---|---|
| Peer hangs forever | An inbound request nobody dispatched, or a response with the wrong id |
| Spurious −32600 replies | A missing jsonrpc field, or JSON that is not a valid message |
| Wrong result for a call | An id reused while still outstanding |
| −32601 on a known method | Traffic 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.
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.