Why MCP Uses JSON-RPC, and What a Message Looks Like
Every MCP message is a JSON-RPC 2.0 request, response or notification. What the specification says about that choice, why it suits the protocol, and a real exchange, message by message: server/discover, tools/list, tools/call, an error and a notification.
7 min read
MCP uses JSON-RPC 2.0 because it needs exactly what JSON-RPC provides and little else: requests with an id that ties each response to its request, one-way notifications that expect no reply, a standard error object with integer codes, and a message format that does not care how it is carried. The specification itself states the requirement, that every message must follow JSON-RPC 2.0, and says MCP takes inspiration from the Language Server Protocol, which is built on JSON-RPC too. It does not set out a longer rationale; the reasons below are a reading of the design, and are marked as such.
If MCP itself is new, start with what MCP is. How the messages travel over stdio and HTTP is covered in MCP transports; this page is about the messages.
What the specification says
The base protocol (opens in a new tab) of the current revision, 2026-07-28, says all messages between clients and servers must follow the JSON-RPC 2.0 specification, and defines three kinds: requests, responses (a result or an error) and notifications. The specification’s overview adds the one piece of history it gives: MCP takes some inspiration from the Language Server Protocol, which standardised how editors gain support for programming languages, and MCP aims to do the same for context and tools in AI applications.
The Language Server Protocol (opens in a new tab) describes its requests, responses and notifications with JSON-RPC, behind a small header carrying the content length. So MCP inherited a proven pattern: many clients, many servers, one message format.
Why it fits: a reading of the design
None of what follows is stated as a reason in the MCP specification. It is what the design needs, set against what the JSON-RPC 2.0 specification (opens in a new tab) offers.
- It is transport agnostic. JSON-RPC describes itself as usable in the same process, over sockets or over HTTP. MCP runs identical messages over stdio and Streamable HTTP, and allows custom transports that keep them.
- Ids make concurrency simple. A client can send several requests down one stdio pipe and match each answer by its id, whatever order they come back in.
- Notifications are built in. Progress, cancellation and change notifications are one-way messages, and JSON-RPC defines a message with no id that must not be answered.
- Errors have one shape. An integer
code, amessageand optionaldatamean every SDK can recognise “method not found” or “invalid params” without a table of its own. - It is small and it is JSON. The whole specification is a few screens long, and JSON is already how tool arguments are written, since MCP describes them with JSON Schema.
- It is not REST. There is one endpoint and the method travels in the body, which is why Streamable HTTP now copies the method and tool name into headers for gateways that cannot read bodies.
The four shapes, and what MCP tightens
- Request:
jsonrpc,id,methodandparams. JSON-RPC allows a null id; MCP does not. An id is a string or an integer, and must not repeat one the sender is still waiting on. - Result: the same
idand aresultobject. Since 2026-07-28 every result carriesresultType,"complete"or"input_required"; a result from an older server with noresultTypeis read as complete. - Error: the same
idand anerrorobject withcode,messageand optionaldata. The codes are in MCP error handling. - Notification:
methodandparamswith noid, and never a reply.
MCP always uses named parameters, an object, never JSON-RPC’s positional array. And every request carries _meta with io.modelcontextprotocol/protocolVersion and io.modelcontextprotocol/clientCapabilities, because since 2026-07-28 there is no initialize handshake to agree them once. A request without them is rejected with -32602.
A real exchange, message by message
These came from a small server built on version 2 of the TypeScript SDK, run over stdio, with one tool, tasks_get. They are reformatted, and the tool list is cut to that one tool.
{
"jsonrpc": "2.0",
"id": 1,
"method": "server/discover",
"params": {
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": { "name": "example-client", "version": "1.0.0" },
"io.modelcontextprotocol/clientCapabilities": {}
}
}
}{
"jsonrpc": "2.0",
"id": 1,
"result": {
"resultType": "complete",
"supportedVersions": ["2026-07-28"],
"capabilities": { "tools": { "listChanged": true } },
"ttlMs": 0,
"cacheScope": "private",
"_meta": { "io.modelcontextprotocol/serverInfo": { "name": "tasks", "version": "1.0.0" } }
}
}server/discover is new in 2026-07-28 and every server must implement it, as the discovery page (opens in a new tab) says. It returns the versions, capabilities and identity that initialize used to negotiate. Clients may skip it and send any request directly; on stdio, a client that must also talk to older servers should send it first, as a probe.
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/list",
"params": {
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {}
}
}
}{
"jsonrpc": "2.0",
"id": 2,
"result": {
"resultType": "complete",
"tools": [
{
"name": "tasks_get",
"description": "Read one task by its reference, such as T-1.",
"inputSchema": {
"type": "object",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": { "ref": { "type": "string" } },
"required": ["ref"]
}
}
],
"ttlMs": 0,
"cacheScope": "private"
}
}ttlMs and cacheScope are required on list results in this revision: how long the client may cache the list, and whether a shared intermediary may. A longer list would also carry nextCursor.
{
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {
"name": "tasks_get",
"arguments": { "ref": "T-1" },
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {}
}
}
}{
"jsonrpc": "2.0",
"id": 3,
"result": {
"resultType": "complete",
"content": [{ "type": "text", "text": "Draft the release notes" }],
"_meta": { "io.modelcontextprotocol/serverInfo": { "name": "tasks", "version": "1.0.0" } }
}
}The arguments are plain JSON checked against the tool’s inputSchema. A tool that fails reports it inside this same result, with isError: true; the JSON-RPC layer only sees success.
{
"jsonrpc": "2.0",
"id": 4,
"error": { "code": -32601, "message": "Method not found" }
}The server answered request 4 with the standard “method not found” code, because ping no longer exists in this revision. On Streamable HTTP the same error travels with status 404.
{
"jsonrpc": "2.0",
"method": "notifications/progress",
"params": { "progressToken": "import-7", "progress": 40, "total": 120, "message": "Imported 40 of 120 tasks" }
}No id, so no reply. The client asked for it by putting progressToken in the request’s _meta, and it arrives before that request’s result. progress must rise with every notification, even when there is no total.
Batching: allowed by JSON-RPC, not by MCP
JSON-RPC lets a client send an array of requests at once. MCP added batching in 2025-03-26, and the 2025-06-18 changelog (opens in a new tab) took it back out. In 2026-07-28 a Streamable HTTP POST must carry a single request or notification, and a stdio line a single message. Do not send arrays: in our test, both the TypeScript and Python SDKs sent nothing back for one, so the client waits until its timeout. The history of each revision is in MCP specification changes.
How the messages travel
- stdio: one JSON-RPC message per line on standard input and output, with no newlines inside a message. Anything else a server writes goes to standard error.
- Streamable HTTP: one POST per message. A request is answered with one JSON object or a short event stream ending in the response; an accepted notification gets
202and no body. Headers such asMCP-Protocol-Version,Mcp-MethodandMcp-Namerepeat what is in the body, and a mismatch is rejected with-32020.
The message is the same either way, which is the point of the design. The full rules for each are in MCP transports.
What changed in 2026-07-28
- The
initializerequest andnotifications/initializedare gone. Version and capabilities travel on every request, and a mismatch returns-32022with the supported versions. server/discoveris new and mandatory for servers.pingandlogging/setLevelare removed; the log level is now per request, in_meta.- Every result carries
resultType, and a server that needs more input returns"input_required"instead of sending the client a request of its own.
fenbs on the wire
fenbs, a task board where AI assistants are members with roles, speaks JSON-RPC 2.0 at https://fenbs.ai/api/mcp, answering every POST with a single JSON object. Its server still uses the earlier handshake revision, 2025-06-18: it answers initialize, and its results carry no resultType, which a current client reads as complete. For clients that only speak stdio, the fenbs-mcp bridge (npx -y fenbs-mcp) reads one JSON-RPC message per line from standard input, posts each to that address with the token in FENBS_TOKEN, and writes each reply to standard output as one line. The tools themselves are listed in the MCP docs.
Related
Failures on both channels: MCP error handling. What to log about these messages: logging for MCP servers. Watching them live: the MCP Inspector. Revision by revision: MCP specification changes.