Skip to content
Chapter 4 of 8 · What MCP is and why it existsThe JSON-RPC handshake

15 min read

The JSON-RPC handshake

On this page

Set MCP_LOG_LEVEL=debug on the client script that connected, discovered and invoked in chapter 2 — one tool served over stdio — and run it again. The three lines of Python produce seven messages on the pipe, and they arrive in an order that is not obvious from the code. There is a request and a response, then a message that gets no reply at all, then another request and response, and only then the call you actually wanted.

If you have only ever seen the SDK's surface, the extra traffic looks like ceremony. It is the part of the system that decides whether your tool is reachable, and it is worth knowing as four steps rather than as one blob, because each of those steps rejects for its own reason and each leaves its own evidence in a log.

Seven messages for three lines of Python

Here is the whole exchange, one JSON-RPC message per line over stdin and stdout — JSON-RPC being a small, long-established envelope in which a request names a method, carries its parameters and an id, and the reply comes back quoting that same id — with -> marking client-to-server and <- the other direction. Nothing has been elided except a few schema fields.

jsonl
-> {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{"roots":{"listChanged":true},"sampling":{}},"clientInfo":{"name":"tickets-cli","version":"0.3.1"}}}
<- {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18","capabilities":{"tools":{"listChanged":true},"logging":{}},"serverInfo":{"name":"support-tickets","version":"1.0.0"}}}
-> {"jsonrpc":"2.0","method":"notifications/initialized"}
-> {"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}
<- {"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"search_tickets","description":"Search the support ticket database by free text. ...","inputSchema":{"type":"object","properties":{"query":{"type":"string"}},"required":["query"]}}]}}
-> {"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"search_tickets","arguments":{"query":"refund","status":"open","limit":3}}}
<- {"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"[{\"id\": 4471, \"title\": \"Refund not received after cancellation\", ...}]"}],"isError":false}}

Seven lines for four steps is arithmetic worth doing before reading any of them, because the mismatch is the first thing that confuses people and it resolves in one sentence. A request-and-response step is two messages, one in each direction. Three of the four steps are that shape — initialize, tools/list, tools/call — so they account for three times two, which is six. The fourth step is the notification, which is one message and gets nothing back, so it adds one. Six and one is seven, and every line in the block above is accounted for with nothing left over. The same reasoning extends: in a session that does nothing but connect, discover and call some number of times, the message count is always odd, because every step contributes two except the single notification. A trace of that shape with an even count has lost a line somewhere, and knowing which parity to expect is cheaper than reading the whole capture.

The sequence is fixed and each step gates the next one. The first step is initialize, which is a request in the ordinary sense — it carries an id, and the server owes a response with the same id. Its job is agreement. The client proposes a protocol version, states what it can do, and identifies itself; the server answers with the version it will actually use and the set of features it offers. Until that exchange completes, no other method is legal, and a well-behaved server rejects anything else that arrives first.

The second step is the one that surprises people, because notifications/initialized has no id. In JSON-RPC a message without an id is a notification: fire-and-forget, no response, no error path back to the sender. The client sends it to say that it has read the server's half of the negotiation and accepted it, which is why the handshake is two messages in each direction rather than one exchange. Without it the server does not know whether the client was satisfied with the version it was offered. A client that sends initialize, reads the response, decides it cannot work with the version it got, and closes the pipe is behaving correctly, and the missing notification is the signal that this is what happened.

Having no id is not a stylistic choice, and following it through explains why this message is the one that goes missing. An id exists so that a reply can be matched to the request that provoked it, which means a message with no id is a message the sender has declared it will not wait for. That has a consequence at each end. The sender cannot learn that it failed, because there is no reply to carry a failure, and there is no reply because there is no id to put in one. The receiver cannot ask for it again, because it has no handle to refer to. And the code that sends it, in a client built out of promises that resolve on a reply, has nothing to await — so it sits in the one place a reviewer's eye slides over, next to lines that all return something. The message is easy to lose for the same reason it is cheap to send, and both come from the missing id.

The third step is tools/list, which is a normal request-and-response and is where the tool declarations travel — a name, a description and an input schema for each. Only the fourth step, tools/call, reaches your handler, and the argument object inside it is the first thing in the whole exchange that your code wrote a single line of.

StepMethodHas idResponseFails when
1initializeyesversion + capabilitiesno shared protocol version
2notifications/initializednononeclient rejected step 1 and stopped
3tools/listyestool declarationsserver declares no tools capability
4tools/callyescontent blocksname unknown, or arguments fail the schema

The two fields inside initialize are worth separating, because they fail differently. protocolVersion is a date string, and the negotiation is not a range check — the client proposes the newest version it supports, and the server either agrees or replies with a different one it does support, at which point the choice is the client's to accept or disconnect. capabilities is a feature declaration on both sides and is not versioned at all. A server that never sets tools in its capabilities is announcing that it has no tools, and a compliant client that reads that will not bother sending tools/list — it will connect happily, discover nothing, and look from the outside exactly like a server whose tool registration silently failed. An SDK sets that capability for you when tools are registered with a decorator; a hand-rolled server, or one where the tools are registered after the server object is constructed, can easily get this wrong.

Client capabilities matter in the other direction and are less familiar because they invert the usual client-server relationship. sampling says the client is willing to run a model call on the server's behalf, so a server can ask the client to complete a prompt without holding an API key of its own. roots says the client will tell the server which directories or URIs it considers in scope. elicitation says the client can put a question to the human and return the answer. These are all things the server may ask of the client mid-session, which means an MCP connection is bidirectional in a way an HTTP API is not — both parties can originate requests, and both must be prepared to receive them.

Why the wire format is JSON-RPC and not something you already deploy

A reasonable objection at this point is that the industry has REST and gRPC and either would have done. Both were plausible and both are wrong for this shape of problem for the same reason, which is that neither is comfortable being run over a pipe to a child process.

JSON-RPC 2.0REST over HTTPgRPC
Needs a socket or portnoyesyes
Works over stdin/stdoutyesawkwardlyno
Server can call the clientyes, same channelnostreaming only
Interface known at build timeno, discoverednoyes, from .proto
Human-readable traceyesyesno, binary
Extra dependency to implementnonenoneprotobuf toolchain

The row that settles it is the first one. A stdio transport exists so a desktop application can launch a tool server as a child process with no network involved at all, which is what makes a local filesystem server or a server holding a user's own credentials possible. A protocol that assumes a listening socket cannot do that. JSON-RPC 2.0, published in 2010 and deliberately transport-agnostic, is a message format and nothing more — it says what a request object looks like, what a response object looks like, and how they are matched by id. Where those bytes travel is somebody else's problem, so the same messages ride newline-delimited over a pipe or in an HTTP body without changing.

The other rows follow from that one rather than standing beside it, which is what makes the table a chain rather than a scorecard. Because a pipe carries bytes in both directions with no notion of who initiated anything, a format designed for it has to encode the direction of a message in the message, which is what an id and a method field do — and that is precisely what allows a server to originate a request back at the client on the same channel. Because the format is text and line-delimited, a trace is something you can read with the same tools you read a log with, whereas a binary framing needs a decoder before it says anything. And because the format specifies only what a message looks like, there is nothing to install: a conforming implementation is a JSON encoder and a dictionary of handlers. Each row is a consequence of not assuming a socket, which is why the first row is the one that settles it.

The Language Server Protocol made exactly this choice a decade ago for exactly this reason, and MCP inherited both the format and the four-step shape of its handshake, including the initialized notification. The lineage is not a coincidence and it is not decoration; it means a protocol trace from an MCP session is readable by anyone who has debugged a language server, and the failure modes are the ones that community already catalogued.

Errors that arrive inside successful responses

There are two distinct error channels in this system and conflating them is the single most common source of confusion when reading a trace. A protocol error is a JSON-RPC error object returned in place of result, with a numeric code from a fixed table: -32700 for unparseable JSON, -32601 for a method the server does not implement, -32602 for invalid parameters. It means the request was not something the server could carry out.

json
{"jsonrpc":"2.0","id":3,"error":{"code":-32602,"message":"Unknown tool: serch_tickets"}}

A tool error is a completely different animal. Your handler ran, and something in the domain went wrong — the ticket database was unreachable, the query was rejected, the invoice already existed. That comes back as a successful JSON-RPC response whose result carries isError: true:

json
{"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"Database connection refused: tickets.db is locked by another process"}],"isError":true}}

The reason for the split is that the two failures have different audiences. A protocol error is for your client's code, which has a bug or a version problem and cannot proceed. A tool error is for the model, which needs to read the text, understand that the search failed, and decide whether to retry with different arguments or tell the user. Returning a domain failure as -32603 hides it from the model entirely, because the client will treat it as a broken connection and abort the loop rather than feeding the message back into the conversation. When you write handlers, the rule that follows is that anything the model could plausibly recover from belongs in isError: true with a description a model can act on, and only genuine protocol violations belong in the error channel.

The rule is easier to apply from the mechanism than from the phrasing, so it is worth tracing what happens to each kind of failure after it leaves your process. A JSON-RPC error is read by your client's transport layer, which is the code that matches replies to requests by id. That code's job is to decide whether the session is still usable, and it has no conversation to add anything to — so what it does with a failure is raise, and the loop unwinds. A result carrying isError is read one layer up, by the code that turns a tool result into a tool_result message, and that code's entire purpose is to put text back into the transcript. Follow a database timeout down each of those two paths and the difference is stark: one ends as an exception in an agent loop that the user sees as the assistant stopping, the other ends as a sentence in the conversation that the model reads and responds to on its next turn. Choosing the channel is choosing which of those two happens, and phrasing it as "is this a protocol problem" hides that the real question is who you want reading the message.

Pagination, absent messages and the silence of the transport

tools/list is paginated: a large catalogue comes back with a nextCursor, and the client keeps asking until there is none. That matters the moment a server exposes more than a page of tools, because a client that ignores the cursor sees a truncated list and reports that half your tools do not exist — a failure that looks like registration going wrong on the server and is entirely in the client.

The absent message is the other thing a trace can tell you, and it is easy to overlook precisely because there is nothing to read. A session that stops after the initialize response, with no notifications/initialized following it, is a client that read the negotiated version and declined it. Nothing errored and nothing will; the pipe simply goes quiet. Because that message has no id and no reply, there is no failure anywhere in the system to correlate it with, and the only way to notice is to know that it should have been there.

The transport, notably, reports none of this. Over streamable HTTP a JSON-RPC error comes back inside a 200 OK, because the HTTP request succeeded — it delivered a message, and the message happened to say no. A client that branches on status codes will see nothing but success on a session that is failing at every step. This is not an accident of implementation; it follows directly from the format being transport-agnostic, since a pipe has no status codes to report through in the first place.

The belief this catches out is a reasonable one, which is why it is worth naming: that monitoring the transport is monitoring the session. It is tempting because it is true almost everywhere else — a REST service that starts refusing work does return fives, and a dashboard built on status codes really does see it. It is false here because a status code describes what happened to the envelope and the failure is written on the letter. Every message in the four-step exchange is carried successfully in the failing cases above: the initialize that gets an unusable version back was delivered, the tools/list that returns nothing was delivered, and the tools/call answered -32602 was delivered. The transport did its job every time. Anything that reads only the transport is therefore watching the one layer that is guaranteed to look healthy, and the diagnosable signal is one layer up, in the bodies nobody parsed.

Four steps, four messages, and only the last of them touches code you wrote. That is the shape worth carrying, because it turns a vague report that "the tool call fails" into a question with a small number of answers: which of the four was the last one to arrive, and what should have followed it. A session that stops after the initialize response failed somewhere entirely different from one that reaches tools/call and gets -32602, and neither has anything to do with your handler.

It is also the shape that explains the bill. The 234 ms of setup and the 31 ms per call are what the decoupling from chapter 1 — one server, many clients, no adapters — actually costs at runtime, and the two numbers are charged to completely different parts of a system's design. What the breakdown does not yet do is separate the 31 ms into the work and the boundary, which is a measurement rather than an inspection, and it needs the model kept out of the loop entirely before it means anything.

What MCP is and why it exists — read the current version at https://daviddominguez.dev/learn/en/what-mcp-is-and-why-it-exists/the-json-rpc-handshake

Back to top