# Your MCP server passes curl and still fails the handshake

> curl never sends notifications/initialized or ping. Four MCP server bugs only a real client found, the rule they broke, and a five-minute probe for yours.

- Author: Chad Priest
- Published: 2026-09-22
- Canonical URL: https://blog.vodou.ai/your-mcp-server-passes-curl-and-still-fails-the-handshake/
- Tags: mcp, jsonrpc, testing, debugging

---

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

1. The `notifications/initialized` reply above.
2. `ping` returned Method not found. Clients ping to check that the server is still alive, so a server that fails `ping` looks dead to any client that checks. curl never pings.
3. 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.
4. 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](https://chatforest.com/guides/mcp-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](https://github.com/kentomk/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:

```bash
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:false` alone means a malformed frame: no `"jsonrpc":"2.0"`, or an `error` that isn't an object with an integer `code`.
- `{"ok":true,"id":2,"method":null,"err":-32601}`: you don't implement `ping`.
- `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:

```bash
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](https://github.com/anthropics/claude-code/issues/37806) 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](https://blog.vodou.ai/your-mcp-server-passes-curl-and-still-fails-the-handshake/) by Chad Priest, from Building Vodou in Public.
