Index Ai Academy
Phase 9 · Model Context Protocol · Protocol foundations

JSON-RPC 2.0 in depth

Requests, notifications, errors and a dispatcher built from scratch.

About 35 minutes · Free lesson

Start this lesson

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

  1. Four kinds of message
  2. Error codes
  3. Which error code?
  4. MCP's extra rules
  5. A dispatcher that never falls over
  6. One message in, at most one out
  7. 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:

messagehas id?has method?has result or error?gets a reply?
requestyesyesnoyes, exactly one
notificationnoyesnonever
result responseyes (same as the request)noresultno
error responseyes (or null if unknown)noerrorno
{"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.

Try it now

Two questions identify any message: does it have an id, and does it have a method?

Quick check
Which of these should a server NOT reply to?
  1. {"jsonrpc": "2.0", "id": "a", "method": "nonsense"}
  2. {"jsonrpc": "2.0", "id": 4, "method": "tools/list"}
  3. {"jsonrpc": "2.0", "id": 0, "method": "server/discover"}
  4. {"jsonrpc": "2.0", "method": "notifications/cancelled", "params": {"requestId": 4}}
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:

codenamewhen
-32700Parse errorthe text isn't valid JSON at all
-32600Invalid Requestvalid JSON, but not a proper request (no method, wrong version)
-32601Method not foundno such method
-32602Invalid paramsthe method exists, but the params are wrong
-32603Internal errorthe 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.

Worked example

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))
It runs right here in your browser, with nothing to install.

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 for true: Python's isinstance(True, int) is True, but a boolean id is invalid.
  • params is 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.
Quick check
A server receives the text {"jsonrpc": "2.0", "id": 3, "method": (cut off mid-message). What should it send back?
  1. An error with code -32601
  2. Nothing, because it's broken
  3. An error with code -32700 and id null
  4. An error with code -32700 and id 3
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:

  1. Parse the JSON. Failure: -32700, id null.
  2. Check the shape: an object, jsonrpc is "2.0", method is a string, and the id (if present) is a string or an integer. Failure: -32600.
  3. If there's no id, it's a notification: run it, ignore any error, and return nothing.
  4. Check params is an object (or missing). Failure: -32602.
  5. Look up the method. Failure: -32601.
  6. 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.

Worked example

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)
In the lesson you can change this and run it.

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.

Quick check
Is this message a request or a notification? What does this print?
import json

msg = json.loads('{"jsonrpc": "2.0", "method": "notifications/progress", "params": {"progress": 5}}')
print("id" in msg)
  1. False
  2. True
  3. None
  4. 5
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

Start this lesson

Try a quiz question

What makes a JSON-RPC message a notification?

  1. It has a method but no id
  2. Its method starts with notifications/
  3. It has an id of 0
  4. It has no params

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson