JSON-RPC 2.0 Testing Guide: Envelopes, Errors and Edge Cases
JSON-RPC 2.0 testing checks the envelope, not the HTTP status: every reply must carry "jsonrpc": "2.0", echo the request id, and hold either a result or an error, never both. A good suite then asserts the documented error codes (-32601 for an unknown method, -32602 for bad params), named and positional params, notifications and batches.
Reviewed by Sushant Joshi
Part of the API Protocols: GraphQL, SOAP, WebSocket, MCP hub. Start with the pillar guide: REST vs GraphQL vs gRPC testing.
JSON-RPC 2.0 is a small protocol with strict rules. A client sends a method name and params, the server replies with a result or an error, and an id ties the two together. It runs blockchain nodes, language servers, internal service APIs and, since 2024, every MCP server. Because the protocol is so simple, teams often test it as if it were REST: send a POST, check for a 200. That misses most of the bugs.
This guide covers what a JSON-RPC 2.0 test suite should assert, in the order the bugs tend to show up.
In this guide
- Why the HTTP status tells you almost nothing
- The envelope rules every reply must follow
- Error codes and what each one should mean
- Params: named, positional and invalid
- Notifications
- Batches
- Describe the API with OpenRPC and generate the first suite
- A pytest suite to start from
- Transport concerns: auth, WebSocket and MCP
- Testing JSON-RPC in Shift-Left API
- Checklist
Why the HTTP status tells you almost nothing
The JSON-RPC 2.0 specification does not mention HTTP. Failure is reported inside the body, as an error object. Most servers that carry JSON-RPC over HTTP therefore return 200 OK for a call that failed, and reserve other statuses for transport problems such as a missing token.
HTTP/1.1 200 OK
Content-Type: application/json
{"jsonrpc": "2.0", "error": {"code": -32601, "message": "Method not found"}, "id": 7}
A test that asserts status == 200 passes on that reply. Every JSON-RPC test should assert on the envelope instead. If you are coming from REST, the HTTP status codes lesson is still useful for the transport layer; it is just not where JSON-RPC puts its answers.
The envelope rules every reply must follow
These rules hold for every method, so write them once as a shared assertion and run them on every reply.
| Rule | What to assert |
|---|---|
| Version | jsonrpc is exactly the string "2.0" |
| Id echo | The reply id equals the request id, with the same type (7 is not "7") |
| Result or error | Exactly one of result and error is present, never both, never neither |
| Error shape | error.code is an integer and error.message is a string; error.data is optional |
| Unparseable request | The reply id is null when the server could not read the request's id |
The id echo deserves its own test with string, integer and large integer ids. Servers that round-trip ids through a float, or cast them to strings, break clients that match replies by id, and the bug only shows up under concurrency.
Error codes and what each one should mean
The specification reserves five codes and a range. A good suite provokes each one on purpose.
| Code | Meaning | How to provoke it |
|---|---|---|
| -32700 | Parse error | Send a body that is not valid JSON |
| -32600 | Invalid request | Send valid JSON that is not a request object, such as {"foo": 1} or an empty array |
| -32601 | Method not found | Call a method that does not exist |
| -32602 | Invalid params | Call a real method with a missing, extra or wrongly typed param |
| -32603 | Internal error | Usually not provokable on purpose; assert it never leaks a stack trace |
| -32000 to -32099 | Server errors, implementation-defined | Whatever your server documents |
Application errors ("insufficient funds", "order not found") should use codes outside the reserved range, documented per method. Test that each method returns its documented code, not a generic -32603 or -32000. A negative test that only asserts "there was an error" passes for the wrong reason more often than you would expect; negative testing covers how to make it specific.
Params: named, positional and invalid
JSON-RPC allows params as an object (named) or an array (positional). A method may accept either or both. Test whichever your API documents, and test the other form too, to confirm it is refused with -32602 rather than misread.
For each param, generate the usual boundary set: missing, null, wrong type, empty string, out-of-range numbers and unexpected extra fields. The cases are the same as for a REST request body; the difference is that the failure must come back as -32602 with a message that names the param. If you keep a schema for params, the techniques in API schema validation apply directly.
Generate test cases from your spec (free)
Free, runs in your browser, no sign-up needed.
Notifications
A request without an id member is a notification. The server must not reply to it, even if the method fails or does not exist. Three tests cover it:
- Send a notification for a real method and assert there is no JSON-RPC reply body (over HTTP, servers commonly answer
204 No Contentor an empty200), then confirm the side effect happened. - Send a notification for a method that does not exist and assert, again, no reply.
- Send a request with
"id": nulland assert you do get a reply. This catches servers that treatid: nulland a missingidas the same thing. They are not:"id": nullis a request (discouraged, but valid) and expects a reply.
Batches
A batch is an array of requests sent in one call. The rules are easy to get wrong, so they deserve their own tests:
- Each request with an id gets exactly one reply. Match replies by id, not by position, because the server may return them in any order.
- A batch that mixes valid and invalid requests returns a reply for each, with errors only on the invalid ones.
- An empty array returns a single -32600 error object, not an empty array.
- A batch made only of notifications returns nothing at all.
- A very large batch is refused or capped. Batches are an easy way to multiply load, so treat the cap as a security control and test it alongside your rate limits.
Describe the API with OpenRPC and generate the first suite
OpenRPC does for JSON-RPC what OpenAPI does for REST. Each method lists its params with JSON Schemas, its result schema and the errors it documents. With that document, most of the suite above can be generated rather than written: a happy-path call per method, a -32601 check for an unknown method, -32602 cases fanned out from each param schema, and an error-code check per documented error.
If your service has no OpenRPC document yet, writing one is usually a day's work and pays for itself in contract testing: a change to a method's params becomes a visible diff, not a client outage.
A pytest suite to start from
# tests/test_jsonrpc.py
import itertools, requests, pytest
URL = "https://rpc.staging.example.com/"
_ids = itertools.count(1)
def call(method, params=None, msg_id="auto"):
body = {"jsonrpc": "2.0", "method": method}
if params is not None:
body["params"] = params
if msg_id == "auto":
msg_id = next(_ids)
if msg_id is not None:
body["id"] = msg_id
return requests.post(URL, json=body, timeout=10), msg_id
def assert_envelope(reply, sent_id):
assert reply["jsonrpc"] == "2.0"
assert reply["id"] == sent_id and type(reply["id"]) is type(sent_id)
assert ("result" in reply) != ("error" in reply), "need exactly one of result/error"
if "error" in reply:
assert isinstance(reply["error"]["code"], int)
assert isinstance(reply["error"]["message"], str)
@pytest.mark.parametrize("msg_id", [1, "abc-123", 2**53 - 1])
def test_id_is_echoed_with_its_type(msg_id):
res, sent = call("order.get", {"id": 42}, msg_id=msg_id)
assert_envelope(res.json(), sent)
def test_unknown_method_is_32601():
res, sent = call("order.nope", {})
reply = res.json()
assert_envelope(reply, sent)
assert reply["error"]["code"] == -32601
# The extra-field case assumes your server rejects unknown params with -32602. If yours accepts them, drop that case.
@pytest.mark.parametrize("params", [{}, {"id": "not-a-number"}, {"id": 42, "extra": True}, [42, 43]])
def test_bad_params_are_32602(params):
res, sent = call("order.get", params)
reply = res.json()
assert_envelope(reply, sent)
assert reply["error"]["code"] == -32602
def test_parse_error_is_32700_with_null_id():
res = requests.post(URL, data="{not json", headers={"Content-Type": "application/json"})
reply = res.json()
assert reply["error"]["code"] == -32700 and reply["id"] is None
def test_notification_gets_no_reply():
res, _ = call("audit.ping", {}, msg_id=None)
assert res.status_code in (200, 204) and not res.content.strip()
def test_null_id_is_a_request_not_a_notification():
body = {"jsonrpc": "2.0", "method": "order.get", "params": {"id": 42}, "id": None}
reply = requests.post(URL, json=body, timeout=10).json()
assert reply["id"] is None and "result" in reply
def test_batch_replies_match_by_id_and_skip_notifications():
batch = [
{"jsonrpc": "2.0", "method": "order.get", "params": {"id": 42}, "id": "a"},
{"jsonrpc": "2.0", "method": "order.nope", "id": "b"},
{"jsonrpc": "2.0", "method": "audit.ping"},
]
replies = {r["id"]: r for r in requests.post(URL, json=batch).json()}
assert set(replies) == {"a", "b"}
assert "result" in replies["a"] and replies["b"]["error"]["code"] == -32601
def test_empty_batch_is_a_single_invalid_request():
reply = requests.post(URL, json=[]).json()
assert isinstance(reply, dict) and reply["error"]["code"] == -32600
OpenAPI to Test Generation Template Pack
Go from OpenAPI spec to full test coverage. Includes sample specs, example generated tests, edge case patterns, and CI/CD integration guides.
Wire it into your pipeline like any other API suite; API testing in CI/CD has the GitHub Actions, GitLab and Jenkins setups.
Transport concerns: auth, WebSocket and MCP
Authentication sits outside the envelope. Over HTTP, a missing or bad token is normally an HTTP 401 before any JSON-RPC processing; some servers instead return a JSON-RPC error in the server range. Decide which yours does, document it and test it, including that a method a user may not call is refused for that user. The authentication and authorization testing guide has the identity-matrix approach.
WebSocket carries JSON-RPC over a held connection, which adds ordering, server-pushed events and reconnects to the test plan. The WebSockets lesson covers the transport basics.
MCP is JSON-RPC 2.0 with a session handshake on top. Every rule in this guide applies, plus a second failure channel: a tool can succeed at the JSON-RPC level and still return isError: true. MCP vs REST APIs for testers explains the difference.
Testing JSON-RPC in Shift-Left API
Shift-Left API by Total Shift Left tests JSON-RPC 2.0 over HTTP as a first-class protocol, next to REST, SOAP and GraphQL. See the JSON-RPC 2.0 and MCP server testing feature for the full detail; in short:
- Import. An OpenRPC document (JSON, by URL, file or paste) becomes one endpoint per method, with local
$refs resolved and documented errors kept. Services with no spec can be imported as a plain method list. - Params and ids. Each endpoint is set to named or positional params, and ids can increment, be a UUID or stay fixed.
- Generated tests. Every method gets a positive test that asserts no error and a result, and a negative test that asserts -32601 for an unknown method. The coverage engine then fans out over each method's params schema, including invalid-params cases that expect -32602.
- JSON-RPC assertions. Dedicated assertions check for no error, an error, a specific error code, an error message, a result, and that the reply id matches the id sent.
- Where it runs. JSON-RPC tests run in workflows, test packs, the scheduler and CI, and on the local runner. Plain JSON-RPC endpoints can also be load-tested with the performance testing add-on.
Limits: batch requests and notifications are not supported (every envelope gets an id), and OpenRPC documents must be JSON, not YAML. JSON-RPC and MCP server testing is available in the Trial and Enterprise editions.
Checklist
- Every reply is checked for
"jsonrpc": "2.0", id echo with the same type, and exactly one of result or error. - Each standard error code is provoked on purpose: -32700, -32600, -32601, -32602.
- Each method's documented application errors return their own codes.
- Named and positional params are tested as documented, and the other form is refused.
- Notifications get no reply, including for unknown methods.
- Batches reply once per id, in any order; an empty batch is one -32600; an all-notification batch returns nothing; large batches are capped.
- Authentication failures behave as documented, and per-method authorization holds for each identity.
- The suite runs in CI against a staging server.
Sources and further reading
- JSON-RPC 2.0 specification
- OpenRPC specification
- Model Context Protocol specification
- How JSON-RPC compares with other API styles for testers: REST vs GraphQL vs gRPC testing
Key takeaways
- A JSON-RPC failure usually arrives as HTTP 200. Assert on the envelope, never only the status.
- Check the id echo with its type, and that a reply holds a result or an error, never both.
- Provoke each standard error code deliberately, and check each method's own documented codes.
- Notifications and batches have precise rules; give each one its own tests.
- An OpenRPC document turns most of this suite into something you can generate.
Ready to shift left with your API testing?
Import your OpenAPI spec and get a runnable test suite, in our cloud or on your own infrastructure.