One MCP server, several clients: that is the normal shape of a deployment, and the specification is
small enough that people expect the wiring to be small too. Then a client hangs, or reports success
while the tool did nothing, and nobody has a stack trace because nothing failed in the sense the
client checks for.
I sent five payloads that real clients actually produce at a reference MCP server and captured every
frame in both directions. Two of the five produced no JSON-RPC error at all, and one produced no
response whatsoever. Here they are, with the bytes.
The setup, so you can reproduce it
-
Server: the official Python SDK,
mcp2.x, a three-tool server (add,boom,echo) running over stdio. Two of those tools behave;boomalways raises. - Client: a raw framer — not the SDK's client, because the whole point is to send malformed but real traffic and watch what comes back. One JSON-RPC message per line, responses collected as they arrive.
- Transport note: stdio keeps the experiment to one variable. The streamable-HTTP path adds headers and sessions on top, which is a different article.
- One server per scenario: the harness starts a fresh process for every payload, so a dropped request or a failed tool cannot leak state into the next case. It costs a couple of seconds per scenario and it removes a whole class of confusing results.
-
What bit first was not the protocol: the server did not import at all until the package rename
above was dealt with, and running the harness from a non-login shell failed with
uv: command not founduntil the script resolved that binary itself. Neither is interesting; both are the kind of thing that eats an afternoon before you reach the part of the article you came for.
One migration fact fell out before I sent anything: on mcp 2.x, FastMCP is gone. The import fails
with "This is mcp 2.x, where FastMCP was renamed to MCPServer" and points at a migration guide.
If your server code was written against 1.x, the first error you meet is an import, not a protocol
behaviour.
Capture 1 — the happy path (so we know what "fine" looks like)
initialize answers with the negotiated version, the capabilities and the server identity:
{"jsonrpc":"2.0","id":1,"result":{"capabilities":{"experimental":{},"prompts":{"listChanged":false},
"resources":{"listChanged":false,"subscribe":false},"tools":{"listChanged":false}},
"protocolVersion":"2025-06-18","serverInfo":{"name":"round86-demo","version":"0.1.0"}}}
tools/list returns each tool with an inputSchema — and, on 2.x, an outputSchema as well,
which is new relative to the 1.x shape most blog posts still show. A call then comes back like this:
{"jsonrpc":"2.0","id":3,"result":{"content":[{"text":"5","type":"text"}],"isError":false,
"structuredContent":{"result":5}}}
Three things to keep from that frame: the payload lives under content as a list of parts, isError
is an explicit boolean, and 2.x adds structuredContent beside the text. A client that reads only
content[0].text still works; a client that wants typed data now has somewhere to get it.
Capture 2 — "params": [], and no response at all
Cursor sends an empty list where the schema expects an object, for tools/list and for the
initialized notification. It is the reason a normalising layer exists in front of a strict server.
Here is what a strict server does with it:
[client->server] {"jsonrpc":"2.0","id":2,"method":"tools/list","params":[]}
… nothing comes back. Not an error, not an empty list. Nothing.
[client->server] {"jsonrpc":"2.0","id":3,"method":"tools/list","params":{}}
[server->client] {"jsonrpc":"2.0","id":3,"result":{"tools":[ …3 tools… ]}}
I re-ran this standalone, waited ten seconds, and the silence held: the request is dropped, and the
only thing the client sees is time passing. That is the worst failure mode in this set, because it
looks like a network problem or a slow server, and the client's error handling never fires.
The fix is unglamorous and it belongs on the receiving side: coerce a non-object params to {},
fail open on anything you cannot parse, and log the method plus the parameter type you saw. Our
production compatibility layer does exactly that (the coerce-and-log function, with the Cursor
comment naming the methods, is documented on the site — see
the compatibility walkthrough),
and the log line is how you learn which client is sending the odd shape instead of guessing.
Capture 3 — the handshake gate is real, and it answers -32602
The lifecycle is a state machine: initialize, then the initialized notification, then requests.
Skip it and a strict server refuses everything:
[client->server] {"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}
[server->client] {"jsonrpc":"2.0","id":2,"error":{"code":-32602,"message":"Invalid request parameters","data":""}}
[client->server] {"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"add","arguments":{"a":2,"b":3}}}
[server->client] {"jsonrpc":"2.0","id":3,"error":{"code":-32602,"message":"Invalid request parameters","data":""}}
Every request, including a perfectly-formed tool call, is rejected with the same code. The message
does not say "you skipped initialization" — it says invalid request parameters, which sends people
looking at the arguments they just sent.
This is why a gateway in front of the server sometimes relaxes the gate on purpose: real clients
reconnect mid-session, or jump straight to tools/list. If you make that choice, record it as a
choice — ours logs "stateless SSE + relaxed init gate", and that sentence is the only thing that
will explain, months later, why a spec-strict reviewer cannot make the server reject a request.
Capture 4 — "unknown tool" is a result, not an error
Ask for a tool that was never advertised:
[client->server] {"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"add_two_numbers","arguments":{"a":2,"b":3}}}
[server->client] {"jsonrpc":"2.0","id":3,"result":{"content":[{"text":"Unknown tool: add_two_numbers","type":"text"}],"isError":true}}
[server stderr] Tool 'add_two_numbers' failed: 'Unknown tool: add_two_numbers'
There is no "error" key anywhere in that response. If your client's error path is written against
JSON-RPC error, this call is a success that happens to contain the word "Unknown". The same
shape comes back for a schema violation — the arguments {"a":"two"} produce isError: true with
the validation message as the content text — so "misnamed tool" and "wrong argument types" are
indistinguishable unless you look at isError and read the text.
Capture 5 — a tool that raises: the exception stays home
boom raises RuntimeError("upstream timeout"). What reaches the client:
[server->client] {"jsonrpc":"2.0","id":3,"result":{"content":[{"text":"Error executing tool boom","type":"text"}],"isError":true}}
[server stderr] mcp.server.mcpserver.exceptions.UnexpectedToolError: Error executing tool boom
The reason string never crosses the boundary; the client gets a generic sentence and a flag. That is
good for leakage and annoying for debugging, and it settles a question I had wrong at first: a tool
failure is data, not a transport failure. Our own site page says a schema mismatch is a client-side
error; the reference SDK instead answers with a result whose isError is true and the validator's
message inside the text. Both can be true — a client may validate early and a server may still refuse
late — but the frame is worth having before you write the error path: mark isError yourself rather
than throwing, and reserve JSON-RPC error for the protocol plane, where the code actually tells the
caller what to do.
The taxonomy that follows from the five captures
Once you put the captures side by side, a client has three failure planes, not one:
| What the client receives | What it means | Where you look |
|---|---|---|
error with -32602
|
negotiation problem: no handshake, or a shape the server refused | lifecycle and params type |
result.isError: true |
the tool plane: unknown tool, bad arguments, the tool raised | the tool name and content[0].text
|
| no response at all | the request was dropped before dispatch | server logs for the method that never answered |
Most client code handles the first plane and treats the other two as success. What fixed it here was not
retries but logging on the server: method, params type, tool name,
isError, wall time. Three of the five captures above then stop being mysteries and become lines
you can grep.
The other half: one server, five config shapes
The wire is only half of "compatible". The config file is the other half, and it is not
interchangeable between clients. Same endpoint, same protocol:
-
mcpServers.smartgatewith"type": "streamable-http"and"url"— the shape most clients accept. -
mcpServers.smartgatewith"serverUrl"— one renamed key; a paste with the wrong one connects to nothing and does not always say so. -
mcp.servers.smartgatewith"transport"— a different nesting and a different key name. - Plus a second header carrying the client's identity, which clients drop more often than they mistype a URL — and which is what per-client quotas and audit records key off.
That is why our config for a client is generated rather than documented: one function per client, so
a wrong key is a missing function instead of a copied row. The generators, with the source of each
shape and the header function, are on
the client-compatibility page;
if you are standing up one server for several clients,
the message-format reference
is the frame-level companion, and
standing up your own server covers the other end.
What this does not prove
The captures are the reference Python SDK 2.x over stdio — a strict, well-behaved implementation.
A server that hand-rolls its JSON processing may answer params: [] with an error, or accept it.
Other SDKs and other transports will differ in detail. The Cursor shape is cited from our production
record rather than re-sent by a real Cursor client here, and the config shapes come from the
generators we ship, not from a survey of every client on the market.
What generalises is the taxonomy: three planes, and only one of them shows up in the error handling
most clients ship on day one.
Disclosure: the captures come from our own instrumented runs, and this write-up was drafted with AI
assistance from those logs.
Top comments (0)