Up to August 5, 2026, the only checks my MCP server had were hand-rolled requests: curl against a server I'd started myself. I sent initialize and got a clean result. I sent tools/list and got tools back. I called a tool and got an answer. I marked it done. The HTTP transport landed at 14:59 that day on the strength of exactly that.
Then I wrote a harness that talks to the server the way a client does: handshake first, then keep the session open. At 21:30 the same day I committed fixes for five bugs it found on its first runs. Four of them are below. The fifth was a process-accounting bug in my own tooling and doesn't generalize. I had been testing my own requests. I had never tested the client's.
{"error":-32601,"id":null} in reply to a notification
Every MCP client sends notifications/initialized right after initialize returns. It's a JSON-RPC notification, so it has no id, and the spec says the server must not reply. My server replied with this:
{"error":-32601,"id":null}
That one frame breaks the spec twice. First, it answers a message that should have gotten silence. Second, it isn't a valid JSON-RPC response: "jsonrpc":"2.0" is missing, and error is a bare number where the spec requires an object with an integer code and a message. Your server could have either bug without the other, so the probe below checks for each one separately.
When I committed the fix, I wrote that a client reading replies in order would end up one frame behind for the rest of the session. I never saw that happen, and I was wrong to put it that way. The harness caught the frame before any real client connected. JSON-RPC clients also match responses by id, not by order. What actually broke was the handshake contract: the first message every client sends got an answer it forbids, in a shape the protocol doesn't define. How a given client copes with that is up to the client. I found out by reading the wire, not from a client's error log. The first real client I attached, Claude Code over stdio, connected after the fix, and it answered "what is the user's dog named" correctly from stored memory.
The embarrassing part is that another MCP server I'd written already stayed silent on notifications. The smoke tests couldn't tell the right server from the wrong one.
Four bugs, zero of them reachable from curl
- The
notifications/initializedreply above. -
pingreturned Method not found. Clients ping to check that the server is still alive, so a server that failspinglooks dead to any client that checks. curl never pings. - CLI flags parsed only in the HTTP entrypoint were never applied in the stdio entrypoint. My install command wrote a restricting flag into the client's stdio config, and stdio was the default transport. So a client configured as restricted wasn't restricted. The restriction had only ever been tested over HTTP.
- When a startup guard refused to launch the server, it printed the refusal as prose to stdout. On stdio, stdout is the protocol channel. The guard trips when more than five engine processes are running. On August 5 that happened on a healthy machine because five unkillable processes left behind by a binary swap were still being counted. None of my curl sessions had ever run with the guard tripped.
A month later, on September 5, the same bug turned up on the client side. My HTTP client transport sent notifications/initialized with an id, which turns it into a request. Remote servers correctly answered -32602, and my client logged that as "this server does not support the notification" on every handshake with every remote HTTP server. So the bug lived on both sides of the wire, and each side read the other's correct behavior as the other's limitation.
The standard advice, as ChatForest's debugging guide puts it, is "never write to stdout" on stdio. That's correct, and it's the advice I would have given. It doesn't cover bug 4. Nobody wrote a stray print(). A guard wrote a polite refusal, and only when a process count crossed a threshold. Grepping for print statements won't find that, and neither will a check at normal startup. mcp-stdio-purity is a good CI gate for banners and late log lines, but it only sees the runs you give it. If the refusal condition isn't true during the run, the gate passes.
Hand-rolled requests test the paths you thought of, not the lifecycle
This isn't really an MCP problem. Any server that speaks a stateful protocol, but has only been tested with requests someone typed by hand, has the same blind spot. curl sends one request and exits. A client runs a handshake, keeps the session open, sends notifications that must not be answered, and stays connected long enough to run into every guard and limit you have.
Invariant: a JSON-RPC server emits exactly one well-formed response per frame that carries an id, zero per frame that doesn't, and nothing on the protocol stream that isn't JSON-RPC. That has to hold on every transport and under every condition the server can start in.
Server-sent notifications don't break this. A server may legally send notifications/message log lines or notifications/tools/list_changed at any point after init. Those carry a method and no id. Answering a client's notification is a different thing: it's a frame with no method and a null id.
Here's the stdio check. Pipe your server the four frames a real client sends and look at every line that comes back:
SERVER="node ./server.js" # your stdio server command
{
printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"probe","version":"0"}}}'
printf '%s\n' '{"jsonrpc":"2.0","method":"notifications/initialized"}'
printf '%s\n' '{"jsonrpc":"2.0","id":2,"method":"ping"}'
printf '%s\n' '{"jsonrpc":"2.0","id":3,"method":"tools/list"}'
sleep 2
} | $SERVER 2>/dev/null | while IFS= read -r line; do
printf '%s\n' "$line" | jq -c '{
ok: (.jsonrpc == "2.0"
and ((has("error") | not)
or ((.error | type) == "object" and (.error.code | type) == "number" and .error.code == (.error.code | floor)))),
id, method,
err: ((.error | objects | .code) // .error)
}' 2>/dev/null || echo "NOT JSON-RPC: $line"
done
The first version of this probe I published couldn't catch my own bug 1. It read .error.code directly, and jq refuses to index a bare number ("Cannot index number with "code""), so the bad frame came out as a parse failure instead of the answer-to-a-notification it actually was. I tested this version against two stub servers: one that emits my exact August frames, and one that behaves correctly and also sends a log notification.
A passing server prints ok:true on every line, exactly one line each for id 1, 2 and 3 with "err":null, and nothing else except lines that have a method starting with notifications/. Those are the server's own notifications, and they're legal. From the good stub:
{"ok":true,"id":1,"method":null,"err":null}
{"ok":true,"id":null,"method":"notifications/message","err":null}
{"ok":true,"id":2,"method":null,"err":null}
{"ok":true,"id":3,"method":null,"err":null}
Failures look like this:
-
{"ok":false,"id":null,"method":null,"err":-32601}: you answered a notification. That was my bug 1.ok:falsealone means a malformed frame: no"jsonrpc":"2.0", or anerrorthat isn't an object with an integercode. -
{"ok":true,"id":2,"method":null,"err":-32601}: you don't implementping. -
NOT JSON-RPC: …: something is writing to your protocol stream.
If you ship Streamable HTTP, the same rule has a one-line check. The correct reply to a POSTed notification is 202 Accepted with an empty body:
URL="http://127.0.0.1:3000/mcp" # your endpoint
H=(-H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream')
SID=$(curl -s -D - -o /dev/null "${H[@]}" -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"probe","version":"0"}}}' "$URL" \
| awk 'tolower($1)=="mcp-session-id:" {print $2}' | tr -d '\r')
[ -n "$SID" ] && H+=(-H "Mcp-Session-Id: $SID")
curl -s -o /dev/null -w '%{http_code} %{size_download}\n' "${H[@]}" -d '{"jsonrpc":"2.0","method":"notifications/initialized"}' "$URL"
202 0 passes. A 200 with any bytes means you answered the notification. My bad stub printed 200 29, which was the same {"error":-32601,"id":null} coming back over HTTP. A 4xx means you rejected a message the spec requires you to accept.
Then run both probes again with every flag your install docs put in a client's config, on each transport. That's where bug 3 was hiding. After that, run them while your server's refusal condition is actually true. You have to set that condition up on purpose. Find what your server checks at startup and make it fail: point HOME or your data-dir variable at a path that doesn't exist, or drop the file-descriptor limit in a subshell with (ulimit -n 16; ./probe.sh). If your guard has a threshold, set it below what's already running, which is how I reproduced bug 4. Whatever the server says about refusing belongs on stderr. The probe drops stderr, so any refusal text that reaches its output is a NOT JSON-RPC: line.
Still open: a probe is a floor, not a client
Four frames are the minimum. They don't cover cancellation, progress notifications, or a client that reconnects in the middle of a session. The bug reports about tools/call invocations that never reach the server show that a clean handshake can still hide a transport failure further along. What I do now is run the probe in CI and then connect a real client before I call anything done. Hand-typed curl tests prove that my server answers the requests I sent. They say nothing about the requests a client sends on its own.
Source: Your MCP server passes curl and still fails the handshake by Chad Priest, from Building Vodou in Public.
Top comments (0)