Skip to content
Chapter 4 of 7 · ArchitectureReading a protocol trace

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

State diagram: start → Transport; Transport → Initialization (bytes flowing); Initialization → Negotiated (initialized sent); Negotiated → Invocation (method is declared); Invocation → start (result or isError); Transport → Failed (ECONNREFUSED, exit 1, 404); Initialization → Failed (-32602 on initialize, timeout); Negotiated → Failed (-32601, method undeclared); Invocation → Failed (-32602, or isError result).

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:

json
{"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.

StageWhat the user seesThe message that proves itRetry ever correct?
TransportServer never appears, or vanishesProcess exit code, ECONNREFUSED, HTTP 404/502, stderr from the childYes, with backoff
InitializationWorks after a delay, or intermittent early errorsPosition of notifications/initialized relative to the failing requestNo: fix the ordering
Capability negotiationA whole feature is dead, consistentlyThe capabilities object in the initialize resultNo: nothing will change within the session
Invocation, bad argumentsOne call fails, same way every time-32602 with a validation message naming a fieldNo: the arguments are wrong
Invocation, tool failureOne call fails, sometimesResult with isError: true and text contentSometimes: 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:

json
{"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:

json
{"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:

text
$ 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 $?
1

Exit 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.

ApproachWhat you getCost
Server-side structured loggingEvery inbound method, session state, connection ageFree once built; misses what the client sent that never parsed
MCP Inspector, the official standalone client for driving a server by handFull bidirectional trace, interactive callsReplaces the real host: reproduces protocol bugs, not host bugs
stdio tee proxyThe exact bytes the real host and server exchangedA 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:

python
#!/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

Back to top