Three MCP rules your JSON-RPC library will happily break
30 July 2026
There is a tool for this on the siteMCP 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.
| Message | Has id | Peer responds |
|---|---|---|
tools/list | yes | yes |
tools/list | no | no |
notifications/initialized | yes | no |
notifications/initialized | no | no |
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 error | Tool failure | |
|---|---|---|
| Shape | {"error": {"code": -32603, ...}} | {"result": {"isError": true, "content": [...]}} |
| Means | The protocol went wrong | The work went wrong |
| Handled by | The client | The model |
| Model sees it | No | Yes |
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-18or later - ids unique for the session
- exactly one of
resultorerroron 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.
-32601often means a typo. arguments, notargs.- A tool failure is
isError: truein 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.