JSON-RPC 2.0 in depth
Requests, notifications, errors and a dispatcher built from scratch.
Why it matters
Every MCP message is JSON-RPC 2.0, so this is the floor everything else in the phase stands on. The spec fits on one page, but the details decide whether a server works with every client or only with yours. Notifications get no answer, ids must match, and a parse error has no id. Bugs here show up as hangs and mysterious timeouts in someone else's app, which makes them painful to debug.
What you'll be able to do
- Tell requests, results, errors and notifications apart, and know which get answers
- Use the standard error codes correctly, from -32700 to -32603
- Follow MCP's extra rules on ids, params and batches
- Build a JSON-RPC dispatcher that never crashes and never leaks internals
What it covers
- Four kinds of message
- Error codes
- Which error code?
- MCP's extra rules
- A dispatcher that never falls over
- One message in, at most one out
- Rules that are easy to get wrong
Four kinds of message
You met JSON-RPC briefly in Phase 1. Here's the full picture, and a busy restaurant is a good way to see it.
A waiter writes an order on a numbered ticket. The kitchen sends back a dish with that number, or a note saying "we're out of that", also with the number on it. Now and then someone calls out "table six is leaving", and nobody answers, because nobody asked anything.
JSON-RPC works the same way. Every message is a JSON object with "jsonrpc": "2.0", and there are exactly four shapes:
| message | has id? | has method? | has result or error? | gets a reply? |
|---|---|---|---|---|
| request | yes | yes | no | yes, exactly one |
| notification | no | yes | no | never |
| result response | yes (same as the request) | no | result | no |
| error response | yes (or null if unknown) | no | error | no |
{"jsonrpc": "2.0", "id": 7, "method": "tools/call", "params": {"name": "get_campaign", "arguments": {"id": "cmp_103"}}}
{"jsonrpc": "2.0", "id": 7, "result": {"resultType": "complete", "content": [{"type": "text", "text": "..."}]}}
{"jsonrpc": "2.0", "id": 8, "error": {"code": -32601, "message": "Method not found: tools/delete"}}
{"jsonrpc": "2.0", "method": "notifications/progress", "params": {"progressToken": "t1", "progress": 40, "total": 100}}
The receiver tells them apart by which keys are present. A message with method and id is a request. With method and no id, it's a notification, and the receiver must not reply, not even with an error. Answering a notification is a classic bug: the other side gets a response for an id it never sent.
The id is how answers find their questions. Like tickets in a kitchen, a client can have many requests in flight at once, over one connection, and replies can come back in any order. So the id is the only link.
In plain words: an id means "someone is waiting for an answer to this". No id means nobody is, so stay quiet.
Two questions identify any message: does it have an id, and does it have a method?
Show the answer
{"jsonrpc": "2.0", "method": "notifications/cancelled", "params": {"requestId": 4}}
No id means notification, and notifications never get a reply. The unknown method still gets an error reply, because it's a request. An id of 0 is a perfectly good id.
Error codes
Error codes are like the stamps on returned mail: "address unreadable", "no such person", "wrong postage". JSON-RPC reserves a few codes for problems with the message itself:
| code | name | when |
|---|---|---|
| -32700 | Parse error | the text isn't valid JSON at all |
| -32600 | Invalid Request | valid JSON, but not a proper request (no method, wrong version) |
| -32601 | Method not found | no such method |
| -32602 | Invalid params | the method exists, but the params are wrong |
| -32603 | Internal error | the server failed while handling it |
The range -32000 to -32099 is for implementations. MCP takes -32020 to -32099 for itself, with names such as UnsupportedProtocolVersion (-32022), which the next lesson uses.
Two subtle rules. First, for a parse error the server can't read the id, so the error response has "id": null. If a letter arrives soaked and unreadable, you can't tell who to answer. Second, an error has a message for people and an optional data field for machines, like the list of supported versions.
Which error code?
Each failure has its own code, and the first checks happen before your tool code runs. Follow one message at a time through the checks.
import json METHODS = {"tools/list": {"params": []}, "tools/call": {"params": ["name"]}} def check(raw): try: msg = json.loads(raw) except ValueError: return -32700, "parse error" if not isinstance(msg, dict) or msg.get("jsonrpc") != "2.0" or "method" not in msg: return -32600, "invalid request" if msg["method"] not in METHODS: return -32601, "method not found" for needed in METHODS[msg["method"]]["params"]: if needed not in msg.get("params", {}): return -32602, "invalid params" return 0, "ok" for raw in ['{"jsonrpc": ', '{"id": 1, "method": "tools/list"}', '{"jsonrpc": "2.0", "id": 2, "method": "tools/delete"}', '{"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {}}', '{"jsonrpc": "2.0", "id": 4, "method": "tools/list"}']: print(check(raw))
The order runs from can't read it, to not a valid message, to no such method, to missing arguments. A precise code tells a client developer exactly where to look. An unhelpful -32603 on everything tells them nothing.
MCP's extra rules
MCP adds a few rules on top of plain JSON-RPC:
- Ids must be a string or an integer, never
null, and never reused while that request is still waiting. Watch out fortrue: Python'sisinstance(True, int)isTrue, but a boolean id is invalid. paramsis always an object with named fields. JSON-RPC also allows a list, but MCP never uses one.- No batches. Plain JSON-RPC lets you send a list of requests at once. MCP removed batching in 2025, so every message is a single object.
- Both directions are allowed in JSON-RPC. In current MCP, clients send requests and servers answer them. Servers also send notifications, such as progress, while working on a request.
{"jsonrpc": "2.0", "id": 3, "method": (cut off mid-message). What should it send back?Show the answer
An error with code -32700 and id null
It isn't valid JSON, so it's a parse error. The server can't trust anything inside, including the id, so the id is null.
A dispatcher that never falls over
A dispatcher turns one incoming message into at most one outgoing message. Think of a mailroom clerk. Every envelope goes through the same steps in the same order, and whatever goes wrong, the clerk sends back a proper note instead of walking out. A good dispatcher has a fixed order of checks, and every failure becomes a proper error response:
- Parse the JSON. Failure: -32700, id
null. - Check the shape: an object,
jsonrpcis"2.0",methodis a string, and the id (if present) is a string or an integer. Failure: -32600. - If there's no id, it's a notification: run it, ignore any error, and return nothing.
- Check
paramsis an object (or missing). Failure: -32602. - Look up the method. Failure: -32601.
- Call it. Errors your code raises on purpose keep their code and message. Anything unexpected becomes -32603 with a generic message, logged privately but never sent with the traceback.
That last step is the framework idea from Phase 8 again: expected errors pass through, and surprises are caught and hidden.
One message in, at most one out
A tiny dispatcher with two methods. Notice the notification produces no output at all.
import json def handle(raw, methods): try: msg = json.loads(raw) except json.JSONDecodeError: return {"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": "Parse error"}} if "id" not in msg: if msg.get("method") in methods: methods[msg["method"]](msg.get("params", {})) return None # notifications get no reply fn = methods.get(msg.get("method")) if fn is None: return {"jsonrpc": "2.0", "id": msg["id"], "error": {"code": -32601, "message": f"Method not found: {msg.get('method')}"}} return {"jsonrpc": "2.0", "id": msg["id"], "result": fn(msg.get("params", {}))} seen = [] methods = { "echo": lambda p: {"you_said": p.get("text")}, "notifications/log": lambda p: seen.append(p["line"]), } for raw in ['{"jsonrpc": "2.0", "id": 1, "method": "echo", "params": {"text": "hi"}}', '{"jsonrpc": "2.0", "method": "notifications/log", "params": {"line": "started"}}', '{"jsonrpc": "2.0", "id": 2, "method": "missing"}', '{"jsonrpc": "2.0", "id": 3, "method": ']: print(handle(raw, methods)) print("logged:", seen)
Four messages in, three replies out: the notification ran (it logged "started") but sent nothing back. This version skips several checks from the list above. The exercise adds them all, which is what makes a dispatcher safe to expose.
import json msg = json.loads('{"jsonrpc": "2.0", "method": "notifications/progress", "params": {"progress": 5}}') print("id" in msg)
Show the answer
False
The message has no "id" key, so in gives False. No id means a notification, and notifications never get a reply, not even an error.
Rules that are easy to get wrong
Never answer a notification. You don't reply to a "wet paint" sign. A notification has no id, which means nobody is waiting for a reply. Sending one anyway confuses clients, since they have nothing to match it against. Even when a notification's method is unknown, stay silent.
Be strict about ids. An id is a string or a whole number. In Python, True counts as an integer, so a careless isinstance(id, int) lets true through as a valid id. Check for booleans first.
A handler's crash must not end the connection. One burned dish shouldn't close the restaurant. Wrap every handler. If it raises, reply with a generic internal error, and put the details in your own logs. The exception text can contain paths or secrets, so it doesn't belong in the reply.
Replies can arrive in any order. A salad can beat a steak to the table, even if it was ordered later. A slow request can finish after a fast one sent later. That's what ids are for. Match on the id, and never assume the next reply belongs to the last request.
Practice what you just read
- 2 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 5 review cards for spaced repetition
Try a quiz question
What makes a JSON-RPC message a notification?