Three MCP rules your JSON-RPC library will happily break

30 July 2026

There is a tool for this on the site

MCP is JSON-RPC 2.0, which is convenient: there is a library for it in every language and you do not have to write a framing layer.

It is also JSON-RPC 2.0 with additional constraints, and a library that implements the base specification correctly will produce messages an MCP peer rejects. The failures are quiet, because the message is well-formed by the standard the library was written against.

1. id must not be null

Base JSON-RPC 2.0 explicitly allows a null id on an error response when the request could not be parsed. There was no id to echo, so null stands in.

MCP removes that allowance. On a request or a response, the id is a string or a number, never null.

{"jsonrpc": "2.0", "id": null, "error": {"code": -32700, "message": "Parse error"}}

Valid JSON-RPC. Invalid MCP. A generic validator passes it, which is exactly why it is worth checking separately: the tooling you would reach for to verify this is the tooling that cannot see the problem.

If you genuinely have no id to echo because the bytes did not parse, that is a transport-level failure rather than a message to send. Log it and close, or send nothing.

2. Batching was removed in 2025-06-18

A JSON-RPC batch is an array of messages in one frame. It was valid under protocol revision 2025-03-26. Revision 2025-06-18 removed it.

A current peer rejects the array. And because the rejection happens before any message inside it is read, the error surfaces as a parse failure rather than a version incompatibility, which sends people to look at their serialiser.

This is also the concrete reason a version downgrade during the handshake matters. If the client asked for 2025-06-18 and the server answered 2025-03-26, the client is now speaking an older revision with different rules. Continuing regardless works until it uses a feature that moved, and then fails in a way that has nothing to do with the code path that broke.

Gate the batch path on the negotiated version, or drop it entirely. Most clients never needed it.

3. Ids are unique for the session, not for the flight

This one is subtle enough to survive a code review by someone who knows JSON-RPC well.

Most implementations track outstanding requests in a map and free the id when the response arrives. That gives uniqueness among the requests currently in flight, which is all the base protocol requires.

MCP requires ids to be unique for the lifetime of the session. Reusing one after its response has arrived is still a violation, and the reason is concrete: a late or duplicated response to the first request gets matched against the second, and a caller receives another request’s answer as its own.

Nothing errors. The data is simply wrong, in a way that looks like an application bug.

A counter that resets per connection in a client that reconnects produces exactly this. So does any pool that hands out ids from a small range.

Use one monotonically increasing counter for the session lifetime. If you need correlation across reconnects, a UUID per request costs a few bytes and removes the class entirely.

The id is what makes a request a request

Not MCP-specific, but the rule that causes the most hangs, so it belongs here.

A message with a method and an id is a request and gets exactly one response. The same message without an id is a notification and gets none, ever, by definition.

MessageHas idPeer responds
tools/listyesyes
tools/listnono
notifications/initializedyesno
notifications/initializednono

Rows two and three are the bugs. Sending notifications/initialized with an id means waiting forever for a reply the protocol says will never come. Sending tools/list without one means the server may well do the work and drop the answer, because there is nothing to address it to.

Neither produces an error. Both produce a hang, and a hang is the hardest thing to attribute because there is no message to inspect.

A tool that failed is not a protocol error

Different channel, and conflating them loses information you need.

JSON-RPC errorTool failure
Shape{"error": {"code": -32603, ...}}{"result": {"isError": true, "content": [...]}}
MeansThe protocol went wrongThe work went wrong
Handled byThe clientThe model
Model sees itNoYes

Returning -32603 Internal error for “file not found” hides the failure from the only participant capable of recovering from it. The model asked to read a file, received nothing it can interpret, and has no basis for trying a different path. Return it as a successful result with isError: true and the reason in content, and the model reads “no such file” and adjusts.

Reserve JSON-RPC errors for genuine protocol problems: unknown method, malformed arguments, a handler that threw for reasons unrelated to the request’s content.

The wrong instinct: write your own validator

When these bite, the reflex is a hand-rolled check in the message path. It usually encodes the rules the author already knew, which are the base JSON-RPC ones, and so it passes exactly the messages that are failing.

The rules that matter here are the differences, and they are few enough to list:

  • id is not null, and is a string or an integer
  • notifications have no id at all, not a null one
  • no batch arrays on 2025-06-18 or later
  • ids unique for the session
  • exactly one of result or error on a response, never both
  • method names are exact and case-sensitive

tools/List gets -32601 Method not found, which reads as a missing feature rather than a typo. That one has cost people real time.

One more, from the params

{"method": "tools/call", "params": {"name": "read_file", "args": {"path": "/tmp/a"}}}

The key is arguments, not args.

With args the tool is invoked with no arguments at all, so it reports a missing required field and the mistake reads as a schema problem rather than a spelling one. Time gets spent on the schema, which is fine.

What changed recently

Streamable HTTP replaced the two-endpoint SSE transport. Not a message-level change, but it lands in the same debugging sessions: a client configured with "type": "sse" against a current server opens a connection that never completes a handshake, which looks like a protocol error and is a transport mismatch.

Elicitation arrived, so servers can ask the user a structured question mid-operation. It is a server-to-client request, which means it needs an id and needs the client capability, and it is a new source of the direction confusion above.

Batching removal is the one to check first on any codebase that predates 2025-06-18 and speaks to a current server.

Debugging it in practice

1. Capture the raw stream. Not the library’s logs. The bytes. Most of these are visible on inspection and invisible through an abstraction that has already normalised them.

2. Check the handshake completed. initialize, its result, then notifications/initialized. A server entitled to refuse work until the third arrives will return an empty tool list and look broken.

3. For a hang, look for a request with no response, or a notification with an id. Those are the two shapes, and a whole-session log makes both obvious.

4. For wrong data rather than no data, check for a reused id. This is the one that produces plausible but incorrect results, and it is invisible in any single message.

When the base rules are enough

If you only ever act as a client talking to well-behaved servers, and you use a maintained MCP SDK rather than a raw JSON-RPC library, most of this is handled. The rules matter when you are implementing the protocol rather than consuming an implementation of it.

If you are on a single fixed protocol revision with both ends under your control, the version-dependent items are settled and you can stop thinking about them. Write the version down somewhere, because “both ends under your control” has a way of stopping being true.

The short version

  • id must not be null. Legal JSON-RPC, forbidden in MCP.
  • No batch arrays from 2025-06-18. The error looks like a parse failure.
  • Ids unique for the session, not merely among open requests. Reuse returns another request’s answer.
  • The id is what makes a request a request. A notification with one hangs.
  • Method names are exact and case-sensitive. -32601 often means a typo.
  • arguments, not args.
  • A tool failure is isError: true in a result, so the model can see it.

Paste a frame into the MCP JSON-RPC message validator and it reports what the message is and which of the MCP-specific rules it breaks. For the session-scoped rules, id reuse and handshake ordering, the stdio session debugger reads a whole log, because one message cannot tell you about the one before it.