DEV Community

PDFops
PDFops

Posted on Originally published at pdfops.dev

Why a valid MCP request still gets a 400 from pdfops.dev

A JSON-RPC body that is fully valid for the 2026-07-28 MCP spec, with a real tool name and valid arguments, can still get a flat 400 from pdfops.dev's hosted MCP endpoint on the very first call. The same request against a local MCP server works fine. The cause is an HTTP header the spec requires alongside the body, separate from the protocol version field already sitting in the JSON-RPC payload, and it is easy to leave off because the body alone looks complete.

The request that looks right and still fails

The 2026-07-28 revision of MCP moved protocol negotiation out of an initialize handshake and into fields carried on every request: params._meta carries io.modelcontextprotocol/protocolVersion and io.modelcontextprotocol/clientCapabilities in the JSON-RPC body itself. A client built against that revision fills those fields in correctly. What the spec also asks for, separately, is an MCP-Protocol-Version HTTP header on the same request, and pdfops.dev's server checks that the two agree rather than trusting either one alone.

curl -X POST https://pdfops.dev/api/mcp \
  -H "Content-Type: application/json" \
  -d '{
    "jsonrpc": "2.0",
    "id": 1,
    "method": "tools/call",
    "params": {
      "name": "pdfops_usage",
      "arguments": {},
      "_meta": {
        "io.modelcontextprotocol/protocolVersion": "2026-07-28",
        "io.modelcontextprotocol/clientCapabilities": {}
      }
    }
  }'

# {"jsonrpc":"2.0","id":1,"error":{"code":-32020,
#   "message":"Header mismatch: MCP-Protocol-Version header is required"}}
# HTTP 400
Enter fullscreen mode Exit fullscreen mode

That is the exact response from a live call: the body is valid JSON-RPC, the tool name and arguments are fine, and the only problem is a header that never made it onto the wire. Over the last 30 days of production traffic, /api/mcp logged 451 separate 400 responses from 55 distinct callers, the whole measured 4xx volume for this endpoint. The log does not record which of the server's several 400 causes fired on each of those rows, so the exact split between a missing header and the other causes below is not something the log can answer. What it does show is that 400 is the only 4xx /api/mcp produces at any real volume, and a missing MCP-Protocol-Version header is one confirmed way to land on it.

The fix is one header, matched to the body

Adding MCP-Protocol-Version: 2026-07-28 to the request, with a value that matches _meta["io.modelcontextprotocol/protocolVersion"] exactly, turns the same body into a normal result:

curl -X POST https://pdfops.dev/api/mcp \
  -H "Content-Type: application/json" \
  -H "MCP-Protocol-Version: 2026-07-28" \
  -d '{
    "jsonrpc": "2.0",
    "id": 1,
    "method": "tools/call",
    "params": {
      "name": "pdfops_usage",
      "arguments": {},
      "_meta": {
        "io.modelcontextprotocol/protocolVersion": "2026-07-28",
        "io.modelcontextprotocol/clientCapabilities": {}
      }
    }
  }'

# {"jsonrpc":"2.0","id":1,"result":{"resultType":"complete",
#   "content":[{"type":"text","text":"PDFops API error 401
#   (missing_api_key): pass your key as X-API-Key"}],"isError":true, ...}}
# HTTP 200
Enter fullscreen mode Exit fullscreen mode

For the 2026-07-28 revision, the server treats the header and the body field as two halves of one claim about the protocol version. If the header says 2026-07-28, or there is no header and the body field says 2026-07-28, both must be present and agree, or the request fails before any tool runs. A client wired to send only one of the two needs the other line added, not a retry or a different tool call. A header naming an older supported revision skips this check.

A few more ways to land on the same 400

A few other request shapes get rejected before any tool runs, with the same style of error. Sending a protocol version the server does not support at all returns -32022 with the list of versions it does accept, so a client pinned to an old draft of the spec gets told exactly what to change to. A body that is not valid JSON at all returns -32700, same as any JSON-RPC server. And a JSON array of several calls in one POST returns -32600: MCP dropped batching support in the 2025-06-18 revision, so the modern spec does not allow it either, and pdfops.dev's endpoint rejects it the same way a spec-compliant client would expect.

# malformed JSON body
curl -s -X POST https://pdfops.dev/api/mcp -d '{not valid json'
# {"jsonrpc":"2.0","id":null,"error":{"code":-32700,"message":"Parse error"}}

# batched array body
curl -s -X POST https://pdfops.dev/api/mcp \
  -d '[{"jsonrpc":"2.0","id":1,"method":"tools/list"}]'
# {"jsonrpc":"2.0","id":null,"error":{"code":-32600,
#   "message":"Batched requests are not supported"}}
Enter fullscreen mode Exit fullscreen mode

Try it

The descriptor at GET https://pdfops.dev/api/mcp and a free key from /docs/signup work without any of this. The header check itself is not limited to tools/list or tools/call: under the modern protocol revision the server checks the header against the body for any method call that carries an id, including server/discover. Sending MCP-Protocol-Version on every call, matched to whatever _meta["io.modelcontextprotocol/protocolVersion"] says, clears the header-mismatch error above. An unsupported version, malformed JSON, or a batched array still get rejected regardless, since those are checked before the header comparison runs.

Top comments (1)

Collapse
 
agentwareshq profile image
agentwares •

Good catch — this is the class of failure that eats the most debugging time, because the body looks valid and the client usually surfaces the 400 as a generic transport error without the JSON-RPC error code. One thing I'd add from the client side: log the raw error payload (-32022 and its message) before anything else, since that's the only place the mismatch is actually named. And pin the version in exactly one place in the client — if the header is set from a constant while _meta is set from whatever the client negotiated at initialize, the two drift apart the moment a server advertises a newer version, and the failure shows up as intermittent 400s instead of a clean version error.