Chapter 4 of 7 · 14 min read
Reading a protocol trace
On this page
A protocol trace is the sequence of JSON-RPC messages two ends of an MCP connection exchanged, in order, and it is the primary diagnostic instrument for anything that goes wrong between them. What makes it useful is not that it shows more detail than a log line but that it is positional: the same error code means different things depending on which messages came before it, and the message that identifies a fault is frequently not the message that reported one.
That is why "the partner's resources support seems broken" and "the initialize result
carried capabilities: {}" are not two ways of saying the same thing. The first is a
symptom with no owner attached; the second names a stage, cites the evidence, and can be
checked by anyone in seconds.
Four stages, four signatures
The states are cheap to distinguish because each transition leaves an artefact. Transport
failures happen before any JSON-RPC exists, so there is no error object to read and the
evidence lives in a process exit code, a connection refusal or an HTTP status.
Initialization failures produce a JSON-RPC error whose id matches the initialize
request, or produce nothing at all and time out. Negotiation failures produce -32601 for a
method the server never advertised. Invocation failures happen inside a method that exists
and was permitted, and they arrive either as -32602 for bad arguments or, for a tool that
ran and went wrong, as a perfectly successful JSON-RPC result whose payload carries
isError: true.
A four-message trace is enough to work an example, and this one is the shape that gets misfiled most often:
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{
"protocolVersion":"2025-06-18",
"capabilities":{"roots":{"listChanged":true}},
"clientInfo":{"name":"acme-desktop","version":"3.4.1"}}}
{"jsonrpc":"2.0","id":1,"result":{
"protocolVersion":"2025-06-18",
"capabilities":{},
"serverInfo":{"name":"partner-docs","version":"2.1.0"}}}
{"jsonrpc":"2.0","method":"notifications/initialized"}
{"jsonrpc":"2.0","id":2,"method":"resources/read",
"params":{"uri":"docs://handbook/onboarding"}}
{"jsonrpc":"2.0","id":2,"error":{"code":-32601,"message":"Method not found"}}Reading that exchange in order is what makes the last line legible, and the useful discipline is to ask, at each message, what it licensed the next one to be. The first message proposes a version and states what the client will answer. The second accepts the version and states what the server will answer, and what it states is nothing. The third closes the handshake, which fixes both of those statements for the rest of the connection. The fourth asks the server to answer a method the second message did not include. By the time the fifth line is written, the outcome was determined two messages earlier, and the fifth line is only the place it became visible. That gap between where a fault is decided and where it is reported is the reason a trace is worth more than an error, and it is not an accident of this example — it is what "positional" means.
The message that settles it is the second one, and specifically the two characters {}.
The server declared no capabilities at all, so resources/read was never a method in this
session, and the -32601 is the correct and complete answer to a request that had no basis.
Nothing is broken on the server's side except possibly its author's intent; the client sent
a request the handshake had already ruled out. A missing handler and an undeclared
capability produce the same error code, and only the earlier response tells them apart.
| Stage | What the user sees | The message that proves it | Retry ever correct? |
|---|---|---|---|
| Transport | Server never appears, or vanishes | Process exit code, ECONNREFUSED, HTTP 404/502, stderr from the child | Yes, with backoff |
| Initialization | Works after a delay, or intermittent early errors | Position of notifications/initialized relative to the failing request | No: fix the ordering |
| Capability negotiation | A whole feature is dead, consistently | The capabilities object in the initialize result | No: nothing will change within the session |
| Invocation, bad arguments | One call fails, same way every time | -32602 with a validation message naming a field | No: the arguments are wrong |
| Invocation, tool failure | One call fails, sometimes | Result with isError: true and text content | Sometimes: depends what the tool did |
The retry column is where the cost of skipping the diagnosis becomes concrete. Exactly one row benefits from a retry, and a client retrying against any of the others is producing load, log volume and a spinner in place of an error message.
That single "yes" is not a coincidence about this table, and deriving it is what lets you fill in the column for a stage nobody has written down yet. A retry can only help when the cause of the failure is something that might stop being true between the first attempt and the second. Test each stage against that. A transport failure is a refused connection, an unreachable host or a process that died — all of them conditions of the world at a moment, any of which can differ a second later, so retrying is exactly right. Capability negotiation produced an object that was fixed the instant the handshake completed and cannot change while the connection is open, so a second identical request meets an identical decision. Bad arguments are wrong for a reason that lives in the request itself, and repeating the same request repeats the same arguments. Initialization ordering is the one that looks like an exception and is not: retrying does succeed, because the notification lands in the interval, but what it repairs is the symptom rather than the client that will do it again. Only the first of those has a cause that lives outside the request, which is the property to test for, and testing it takes one question rather than a table lookup.
Transport failures over HTTP are worth separating out, because the status code alone names
the fault and people skip past it on the way to the body. A 404 on the MCP endpoint means
the path is wrong or the server is not mounted where the configuration says; a 405 usually
means the client attempted a GET for the server-to-client stream against a server that
does not offer one; a 401 means authorisation is the issue and the WWW-Authenticate
header carries where to go next; a 502 or a connection reset mid-stream means something in
front of the server — a load balancer with a 60-second idle timeout is the usual culprit —
closed a long-lived stream that the client expected to stay open. Each of those is a
different team's problem and none of them is a protocol question, which is why reaching for
the trace before reading the status wastes the first twenty minutes.
The invocation stage splits along the same seam. A -32602 is the server telling you the
request never reached the tool, because the arguments did not match the declared input
schema:
{"jsonrpc":"2.0","id":9,"error":{"code":-32602,
"message":"Invalid arguments for create_ticket: 'priority' must be one of ['low','medium','high'], got 'urgent'"}}That message identifies the sender of the bad argument, which in an agent host is almost
always the model rather than the code. The repair is a schema description the model can
follow or an enum the provider will constrain against, and retrying the identical call
cannot help because nothing about the request will differ. Filed as "the ticketing server is
rejecting calls" it looks like a server fault; read as -32602 naming a field and a
permitted set, it is a prompt or schema problem in the host.
The isError row is the one most often misfiled, and it is worth being precise about what
it is not. A tool that runs and fails — the API it wraps returned 403, the file was not
there, the SQL was invalid — does not produce a JSON-RPC error. It produces a normal result:
{"jsonrpc":"2.0","id":7,"result":{
"isError":true,
"content":[{"type":"text",
"text":"create_ticket failed: project KEY-9 not found"}]}}The distinction is deliberate and it exists because of who the audience is. A protocol error
is addressed to the client and means the request was not something the server could process.
An isError result is addressed to the model, which is why the failure text lives in
content where it will be fed back into the next turn; the model reads "project KEY-9 not
found" and can try a different project. A client that flattens both into a thrown exception
has thrown away the only channel by which a tool failure can be repaired, and it has also
collapsed the two most useful stages of the table into one bucket.
The belief worth dismantling here is that an error is an error and the client should treat
them uniformly, which is tempting because uniform error handling is usually good advice. It
fails because these two are not two severities of the same thing; they are addressed to
different readers. A protocol error is the server telling the client's own code that the
request could not be processed, and the client's code is the only party that can act on it —
the model cannot fix a malformed request it never saw. An isError result is the server
telling the model that the operation it asked for did not work, and the model is the only
party that can act on that, by asking for something different. Flattening both into a
thrown exception routes the second message to the party that cannot use it and discards it
before the party that could ever sees it. The test for any error-handling layer in an agent
is therefore not whether it handles failures consistently, but whether it preserves who each
failure was written for.
Citing the message, not narrating the symptom
The discipline that makes this fast is to state a diagnosis and the message that proves it
in the same sentence. "Capability negotiation failed: the initialize result at
09:41:02.118 carried capabilities: {}" is a claim anyone can check in seconds and nobody
can argue with. "The partner's resources support seems broken" is a claim that generates a
meeting.
Applied to an intermittent tool-call failure of the kind that comes from a handshake race,
the diagnosis is an initialization-stage failure, and the two messages to cite are
notifications/initialized and the failing tools/call, in the order the server recorded
them. If the notification is logged after the call, the diagnosis holds. If it is absent
from that connection entirely, the diagnosis changes to a client that only sends it on some
code paths, which is a different fix in a different file. If it is logged before the call,
the hypothesis is dead and you go looking at whether the tool name is registered under a
different string. All three outcomes come from the same two lines of log, which is why
asking for them is a better first move than reading the client's source.
The same discipline applies to the stage nobody captures, because transport failures leave their evidence outside the message stream. A stdio server that crashes during startup writes to stderr and exits, and the client sees a closed pipe with no JSON at all:
$ npx -y @acme/docs-mcp --root /var/docs
node:internal/fs/utils:350
throw err;
Error: ENOENT: no such file or directory, scandir '/var/docs'
$ echo $?
1Exit code 1 with an ENOENT on stderr is a complete diagnosis and it never reaches the
protocol layer. Hosts that discard the child's stderr — which is easy to do, since the
interesting stream is stdout — turn that into "the server disconnected", which is a symptom
shared with half a dozen unrelated faults. Capturing stderr to a per-server log file is the
single highest-yield thing a host can do for its own supportability.
The reason that particular diagnosis lands outside the trace, rather than merely being missing from it, is worth following because it explains why no amount of protocol tooling would recover it. A trace is a record of messages, and a message exists only once a process has written one. This process wrote nothing to stdout at all: it failed while opening a directory, which happens during startup, which is before any handshake and therefore before there is anything to record. What it did write went to the other stream, the one carrying diagnostics rather than protocol, and that stream is not part of the conversation the trace is a record of. So a complete and correct trace of this session is empty, and the emptiness is not a gap in the instrument. The evidence exists, in full, one stream over — and the only way it reaches anybody is if the host that owns the child process chose to keep it.
Getting a trace when you do not own both ends
The method depends on which side you control, and there are three practical options with different costs.
| Approach | What you get | Cost |
|---|---|---|
| Server-side structured logging | Every inbound method, session state, connection age | Free once built; misses what the client sent that never parsed |
| MCP Inspector, the official standalone client for driving a server by hand | Full bidirectional trace, interactive calls | Replaces the real host: reproduces protocol bugs, not host bugs |
| stdio tee proxy | The exact bytes the real host and server exchanged | A wrapper process in the path; only works for local stdio servers |
The proxy is the one people do not think of and it is fifteen lines, because a stdio server is just a process reading newline-delimited JSON on stdin and writing it on stdout.
Why so little code suffices is worth spelling out, because the same reasoning tells you when this technique will not work. The host's entire contract with a stdio server is: run this command, write to its stdin, read from its stdout. Nothing in that contract identifies the process, so any program satisfying it can stand in — and a program that spawns the real server, copies stdin into its stdin and its stdout back out, satisfies it exactly. Copying bytes in both directions while writing them down is the whole of a wrapper of this shape, and there is nothing else to implement because there is nothing else in the contract. The same argument marks where it stops: an HTTP server's contract involves an address, a session header and routing, so the equivalent interposition there is a reverse proxy rather than fifteen lines of pipe-copying.
Point the host's configuration at the wrapper instead of the real command:
#!/usr/bin/env python3
import subprocess, sys, threading, datetime
log = open("/tmp/mcp-trace.jsonl", "a", buffering=1)
child = subprocess.Popen(sys.argv[1:], stdin=subprocess.PIPE, stdout=subprocess.PIPE)
def pump(src, dst, direction):
for line in src:
ts = datetime.datetime.now().isoformat(timespec="milliseconds")
log.write(f'{{"t":"{ts}","dir":"{direction}","msg":{line.decode().strip()}}}\n')
dst.write(line)
dst.flush()
threading.Thread(target=pump, args=(sys.stdin.buffer, child.stdin, "c2s"), daemon=True).start()
pump(child.stdout, sys.stdout.buffer, "s2c")The timestamps are the point. A trace without them cannot answer ordering questions, and
ordering is what the initialization stage turns on; a trace with millisecond stamps in both
directions settles a handshake race in one grep. This will not help with a remote HTTP
server, where the equivalent is a logging reverse proxy or the server's own access log
correlated by Mcp-Session-Id.
What none of these give you is the host's decision-making. A trace shows that tools/call
was never sent; it does not show that the host suppressed it because the user declined a
consent dialog, or because the tool was filtered out of the aggregated array before the
provider ever saw it. Those are host-internal events, invisible on the wire, and when a
trace contains nothing at all for an action the user swears they took, the absence of a
message is itself the finding: the failure happened above the protocol, and the next place
to look is the host's own log.
Four stages, four signatures, and one retry column with a single yes in it. That is the
whole instrument, and its value is that it converts an argument about whose fault something
is into a question with a citable answer. The error code alone never carries the diagnosis —
-32601 means an undeclared method or an unregistered handler, -32602 means bad arguments
or a mangled tool name, and a closed pipe means anything at all — so the message that proves
the stage is almost always an earlier one than the message that reported the failure.
Naming a stage tells you where a failure came from; it does not tell you what the software
should have done instead. A client that reads capabilities: {} and then sends
resources/read anyway has no branch to take when it stops retrying, and choosing what that
branch should do is a design question rather than a debugging one.
Architecture — read the current version at https://daviddominguez.dev/learn/en/architecture/reading-a-protocol-trace