Chapter 4 of 8 · 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.
-> {"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.
| Step | Method | Has id | Response | Fails when |
|---|---|---|---|---|
| 1 | initialize | yes | version + capabilities | no shared protocol version |
| 2 | notifications/initialized | no | none | client rejected step 1 and stopped |
| 3 | tools/list | yes | tool declarations | server declares no tools capability |
| 4 | tools/call | yes | content blocks | name 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.0 | REST over HTTP | gRPC | |
|---|---|---|---|
| Needs a socket or port | no | yes | yes |
| Works over stdin/stdout | yes | awkwardly | no |
| Server can call the client | yes, same channel | no | streaming only |
| Interface known at build time | no, discovered | no | yes, from .proto |
| Human-readable trace | yes | yes | no, binary |
| Extra dependency to implement | none | none | protobuf 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.
{"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:
{"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