prerequisite
JSON-RPC
The request, response, notification, and error shapes MCP borrows: ids, methods, params, results, and error codes.
Before this
This page assumes you are comfortable with:
Why you need this
MCP does not invent its own message format. Every MCP message is a JSON-RPC 2.0 message with MCP-specific contents. Once you know the four shapes on this page (request, success response, error response, notification), every MCP message you ever see is one of them, and most bugs at the protocol level are a mismatch in one of their few fields.
The idea
A remote procedure call (RPC) is calling a function that lives in another program. You name the function, pass the arguments, and get back either a return value or an error, as if it were a local call. The difference is that the call travels as a message, so it can be slow, can fail in transit, and can be answered by a program written in another language on another machine.
JSON-RPC 2.0 is a small, widely used standard for writing those messages in JSON. It says nothing about how messages travel (a pipe, an HTTP request, a socket), only what they look like. It defines four shapes.
Request
{"jsonrpc": "2.0", "id": 7, "method": "convert", "params": {"value": 5, "factor": 2.54}}
| Field | Meaning |
|---|---|
jsonrpc |
Always the string "2.0". |
id |
A number or string the sender picks. The reply will carry the same id. |
method |
The name of the function to call. |
params |
The arguments, as an object (named) or an array (by position). May be left out. |
Success response
{"jsonrpc": "2.0", "id": 7, "result": 12.7}
result can be any JSON value. It is present only on success.
Error response
{"jsonrpc": "2.0", "id": 7, "error": {"code": -32602, "message": "Invalid params", "data": "factor is missing"}}
A response has either result or error, never both. The error object has an integer code, a short message for humans, and an optional data field with anything extra.
Notification
{"jsonrpc": "2.0", "method": "progress", "params": {"done": 3, "of": 10}}
A notification is a request with no id. The receiver must not reply to it, not even with an error. Use it for "for your information" messages where the sender does not need to know what happened.
Why the id matters
A sender can have many requests in flight at once on the same connection. Replies come back in whatever order the work finishes, not the order the requests were sent. The id is the only thing that ties a reply to its request, so the sender keeps a table of ids it is still waiting for, and each reply removes one entry. Two consequences follow: an id must not be reused while a request with that id is still waiting, and a reply with an id the sender does not recognize is a bug somewhere.
Base JSON-RPC allows "id": null. MCP does not: in MCP an id is always a string or an integer, and it must not match any other request the sender is still waiting on.
Error codes
JSON-RPC reserves the integers from -32768 to -32000 for itself. Five codes are standard:
| Code | Name | Meaning |
|---|---|---|
| -32700 | Parse error | The text was not valid JSON. |
| -32600 | Invalid Request | Valid JSON, but not a valid request (for example, no method). |
| -32601 | Method not found | No such method. |
| -32602 | Invalid params | The method exists, but the arguments are wrong. |
| -32603 | Internal error | Something broke inside the receiver. |
The range -32000 to -32099 is set aside for "server errors" that an implementation defines. MCP divides that range in its Base Protocol section:
| Range | Use in MCP (2026-07-28) |
|---|---|
| -32000 to -32019 | Legacy. Codes here were picked by implementations before MCP had a policy. New code should not use them, and a receiver should not assume they mean anything, except -32002 (an older "resource not found"). |
| -32020 to -32099 | Reserved for the MCP specification. Only codes the specification defines may be sent. So far: -32020 header mismatch, -32021 missing required client capability, -32022 unsupported protocol version. |
An application that needs its own error codes should pick integers outside the whole reserved block, for example positive numbers.
When the receiver cannot read the id at all (because the text was not valid JSON), it replies with "id": null, since it has nothing better to put there.
Worked example
Three requests leave a client at the same moment. They take different amounts of time on the server, so the replies arrive out of order. The client matches each reply to its request by id.
This file holds a tiny JSON-RPC server (the handle function) and a client that sends three requests at once. It uses only Python's standard library; the asyncio module lets the three requests run concurrently.
import asyncio
import json
# The server side: three methods that take different amounts of time.
async def lookup_weather(city):
await asyncio.sleep(0.3) # pretend a slow web API
return {"city": city, "temp_c": 11}
async def convert(value, factor):
await asyncio.sleep(0.1)
return value * factor
async def get_time():
return "14:05"
METHODS = {"lookup_weather": lookup_weather, "convert": convert, "get_time": get_time}
async def handle(text):
"""Take one message as text, return the reply as text (or None)."""
try:
msg = json.loads(text)
except json.JSONDecodeError:
return json.dumps({"jsonrpc": "2.0", "id": None,
"error": {"code": -32700, "message": "Parse error"}})
if not isinstance(msg, dict) or msg.get("jsonrpc") != "2.0" or "method" not in msg:
return json.dumps({"jsonrpc": "2.0", "id": msg.get("id") if isinstance(msg, dict) else None,
"error": {"code": -32600, "message": "Invalid Request"}})
is_notification = "id" not in msg
func = METHODS.get(msg["method"])
if func is None:
reply = {"error": {"code": -32601, "message": "Method not found", "data": msg["method"]}}
else:
try:
result = await func(**msg.get("params", {}))
reply = {"result": result}
except TypeError as e:
reply = {"error": {"code": -32602, "message": "Invalid params", "data": str(e)}}
if is_notification:
return None # never answer a notification
return json.dumps({"jsonrpc": "2.0", "id": msg["id"], **reply})
# The client side: send three requests at once, print replies as they arrive.
async def main():
requests = [
{"jsonrpc": "2.0", "id": 1, "method": "lookup_weather", "params": {"city": "Oslo"}},
{"jsonrpc": "2.0", "id": 2, "method": "convert", "params": {"value": 5, "factor": 2.54}},
{"jsonrpc": "2.0", "id": 3, "method": "get_time"},
]
pending = {r["id"]: r["method"] for r in requests}
tasks = [asyncio.create_task(handle(json.dumps(r))) for r in requests]
for finished in asyncio.as_completed(tasks):
reply = json.loads(await finished)
print("arrived:", json.dumps(reply), "-> answers", pending.pop(reply["id"]))
print()
for bad in ['{"jsonrpc": "2.0", "id": 4, "method": "convert", "params": {"value": 5}}',
'{"jsonrpc": "2.0", "id": 5, "method": "teleport"}',
'{"jsonrpc": "2.0", "id": 6, "method": ',
'{"jsonrpc": "2.0", "method": "get_time"}']:
print(await handle(bad))
asyncio.run(main())
Run it with python rpc_demo.py. It prints:
arrived: {"jsonrpc": "2.0", "id": 3, "result": "14:05"} -> answers get_time
arrived: {"jsonrpc": "2.0", "id": 2, "result": 12.7} -> answers convert
arrived: {"jsonrpc": "2.0", "id": 1, "result": {"city": "Oslo", "temp_c": 11}} -> answers lookup_weather
{"jsonrpc": "2.0", "id": 4, "error": {"code": -32602, "message": "Invalid params", "data": "convert() missing 1 required positional argument: 'factor'"}}
{"jsonrpc": "2.0", "id": 5, "error": {"code": -32601, "message": "Method not found", "data": "teleport"}}
{"jsonrpc": "2.0", "id": null, "error": {"code": -32700, "message": "Parse error"}}
None
Walk through it as a timeline.
| Time | Event | Client's pending table after it |
|---|---|---|
| 0.0 s | Sends ids 1, 2, 3 | {1: lookup_weather, 2: convert, 3: get_time} |
| 0.0 s | Reply id 3 arrives (no waiting) | {1: lookup_weather, 2: convert} |
| 0.1 s | Reply id 2 arrives | {1: lookup_weather} |
| 0.3 s | Reply id 1 arrives | {} |
The replies came back as 3, 2, 1. If the client had assumed "first reply answers first request", it would have shown the time as the weather. The id made the order irrelevant.
The second half shows the error shapes. Id 4 names a real method with a missing argument, so it gets -32602. Id 5 names a method that does not exist, so -32601. The third message is cut off mid-text, so the server cannot read its id and answers with "id": null and -32700. The last message has no id, so it is a notification: the server ran get_time and sent nothing back.
In a server's life
This is the envelope for stage 1, speak the protocol. MCP adds rules on top (every request carries protocol metadata, every result says what kind of result it is) but never changes these four shapes. The error codes come back in stage 6, maintain it, as the numbers worth counting and alerting on.
Common mistakes
- Replying to a notification. The sender is not waiting for it, so the reply arrives with no matching id. Symptom: the other side logs "unknown id" warnings or, with a strict peer, drops the connection.
- Reusing an id while a request is still in flight. Two replies match one table entry. Symptom: one caller gets the other caller's answer, and the second reply is reported as unknown.
- Matching replies by order. Works in testing, where everything is fast, and fails under load. Symptom: results occasionally attached to the wrong question.
- Both
resultanderror, or neither. Symptom: clients disagree about whether the call succeeded. - Inventing codes inside -32020 to -32099 for your own errors. MCP reserves that range, and a future revision may give your number a different meaning. Use a code outside the reserved block, or put the detail in
data.
Cost
Each message adds a few dozen bytes of envelope (jsonrpc, id, method) on top of the payload, which is negligible next to any real work. The client's pending table costs memory proportional to the number of requests in flight, and lookups are constant time with a dictionary keyed by id. The real cost is in your own code: forgetting to remove entries for requests that time out turns that table into a slow memory leak.
Going further
- Anatomy of an MCP request, for what MCP puts inside
paramsandresult. - The JSON-RPC 2.0 specification itself, which is short enough to read in one sitting.
- Batching, a JSON-RPC feature that sends an array of requests in one message (MCP does not use it).
- Request timeouts and cancellation, the next problem once requests can be in flight.