Understanding the JSON-RPC protocol and how it's used in MCP
Kashish Hora
Co-founder of AgentCat
Every message an MCP client and server exchange is a JSON-RPC 2.0 message (MCP spec, Messages). Here's the honest version, though: you don't need to know that to build an MCP server. The SDKs assemble these messages for you, and day to day you just write tool and resource handlers. Where it pays off is when something breaks, or when you're simply curious what's crossing the wire. Knowing JSON-RPC's three message shapes and how MCP layers method names on top of them is what turns a cryptic "the server won't connect" into something you can actually debug.
This guide is the protocol reference the rest of the connection-management guides point back to. It covers the three message types, the fields JSON-RPC requires, how MCP tightens a few of those rules, the per-request metadata that carries the protocol version and capabilities, the core method families (tools and resources), and the error codes that are actually part of the standard (as opposed to the ones tutorials tend to invent). Everything here describes the current spec revision, 2026-07-28, with short notes where it changed something the previous revisions taught.
The short version
MCP uses JSON-RPC 2.0 as its wire format. A client asks the server to do something with a request, the server answers with a response, and either side can send a fire-and-forget notification. A request carries a method name like tools/list and correlates to its response by id.
// Request: client asks the server to list its tools
{ "jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {} }
// Response: server replies with the same id
{ "jsonrpc": "2.0", "id": 1, "result": { "resultType": "complete", "tools": [] } }MCP doesn't invent a new envelope. It reuses JSON-RPC's envelope and defines a vocabulary of method names (server/discover, tools/call, resources/read, and the rest) plus the shapes of their params and result objects.
The three message types
JSON-RPC 2.0 defines three message shapes, and MCP uses all three (MCP spec, Messages).
Request. A call that expects an answer. It carries jsonrpc: "2.0", a method string, an optional params, and an id that the response must echo back. Every MCP request also carries a _meta block inside params that declares the protocol version and the client's capabilities, covered in the next section; the examples here leave it out for readability, the way the spec's own examples do.
{
"jsonrpc": "2.0",
"id": 2,
"method": "resources/read",
"params": { "uri": "file:///config.json" }
}Response. The answer to a request. It carries the same id and exactly one of result (on success) or error (on failure). The JSON-RPC spec is strict here: it's one or the other, never both (JSON-RPC 2.0, Response object).
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"resultType": "complete",
"contents": [{ "uri": "file:///config.json", "text": "..." }]
}
}Every result must include a resultType string. "complete" means what it says: the request finished and the rest of the result is the answer. "input_required" means the server needs something from the client (a user prompt, a sampling call) before it can finish, and the client supplies it and retries. Extensions may define more. A result that arrives with no resultType at all comes from a server on an older revision, and clients must read it as "complete" (MCP spec, ResultType).
Notification. A one-way message with no id. The receiver does not send a response (MCP spec, Notifications). MCP uses notifications for things that do not need an acknowledgement, like telling the other side a list changed or a long operation is making progress.
{
"jsonrpc": "2.0",
"method": "notifications/resources/updated",
"params": { "uri": "file:///config.json" }
}The presence or absence of id is what distinguishes a notification from a request. A JSON-RPC notification is a request object without an id member (JSON-RPC 2.0, Notification).
Two rules MCP adds to plain JSON-RPC
Base JSON-RPC allows a request id to be a string, a number, or null. MCP tightens this in two ways that matter when you're debugging correlation problems (MCP spec, Requests).
- The request
idmust not benull, even though plain JSON-RPC permits it. - The request
idmust not match the id of any other request the sender has issued and not yet received a response for.
Both rules exist so that a client can always match a response to the exact request that produced it. If you reuse an id that's still in flight, or send null, a spec-compliant peer is within its rights to reject the message. This is the first thing to check when responses seem to land against the wrong request.
How version and capabilities travel: per-request _meta
There is no handshake. As of the 2026-07-28 revision MCP is a stateless protocol, and every request carries everything the server needs to process it, so a server may not infer anything from earlier requests on the same connection (MCP spec, Statelessness). The protocol version and the client's capabilities ride in a _meta object inside params, on every single request (MCP spec, _meta).
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "get_weather",
"arguments": { "location": "New York" },
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": { "elicitation": { "form": {} } },
"io.modelcontextprotocol/clientInfo": { "name": "ExampleClient", "version": "1.0.0" }
}
}
}Two of those keys are required on every request: io.modelcontextprotocol/protocolVersion and io.modelcontextprotocol/clientCapabilities. io.modelcontextprotocol/clientInfo is a SHOULD. A request missing a required field is malformed, and the server rejects it with -32602 (MCP spec, _meta). Going the other way, servers should put io.modelcontextprotocol/serverInfo in each result's _meta. Both clientInfo and serverInfo are self-reported and unverified, so they're for display and logs, not for security decisions.
Version agreement follows from this rather than from a negotiation step. Each request declares its version and the server accepts or rejects that request on its own. If the server doesn't implement the version, it answers with UnsupportedProtocolVersionError (-32022) listing what it does support, and the client picks one and retries (MCP spec, Version Negotiation).
{
"jsonrpc": "2.0",
"id": 1,
"error": {
"code": -32022,
"message": "Unsupported protocol version",
"data": { "supported": ["2026-07-28", "2025-11-25"], "requested": "1900-01-01" }
}
}server/discover, the one method every server implements
Servers must implement server/discover, which returns the protocol versions a server supports, its capabilities, its identity, and optional instructions (MCP spec, Discovery). Clients aren't required to call it. It's a convenience, one request that answers "what is this server and what does it do" instead of probing with tools/list, prompts/list, and resources/list, and it's the recommended first request on stdio when a client needs to tell a current-revision server from an older one.
// Request: no params beyond _meta
{
"jsonrpc": "2.0",
"id": "discover-1",
"method": "server/discover",
"params": {
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {}
}
}
}
// Result
{
"jsonrpc": "2.0",
"id": "discover-1",
"result": {
"resultType": "complete",
"supportedVersions": ["2026-07-28"],
"capabilities": { "tools": {}, "resources": {} },
"_meta": {
"io.modelcontextprotocol/serverInfo": { "name": "ExampleServer", "version": "1.0.0" }
},
"instructions": "This server provides weather and resource utilities.",
"ttlMs": 3600000,
"cacheScope": "public"
}
}Capabilities are objects, not booleans
Capabilities are still nested objects, not true/false flags; what changed is where they travel. A server that supports tools declares a "tools" object (even an empty "tools": {} advertises support), and one that supports resource subscriptions declares "resources": { "subscribe": true }. The nested keys carry real meaning: listChanged says the server will emit a notification when its list of tools, prompts, or resources changes, and subscribe (resources only) says the client can subscribe to changes on an individual resource. Writing "tools": true isn't the schema, and a peer that validates capabilities won't read it as "tools enabled."
A server must not rely on a capability the client didn't declare in that request's _meta. If it needs one, it returns MissingRequiredClientCapability (-32021) with the missing capabilities in data.requiredCapabilities (MCP spec, _meta). Same guarantee the old handshake gave, enforced per request instead of once per connection.
Before 2026-07-28: the initialize handshake
Earlier revisions opened every connection with a three-message exchange: an initialize request carrying the client's protocolVersion, capabilities, and clientInfo; an initialize response carrying the server's version, capabilities, and serverInfo; and a notifications/initialized notification from the client to close it out. Version and capabilities were negotiated once and held for the life of the session. The 2026-07-28 revision removes initialize and notifications/initialized outright, along with the session they established.
That history is still operationally relevant, because most shipping clients and SDK releases still speak 2025-11-25 or earlier today. If you're writing a server people will actually connect to, serve both eras rather than the current revision alone; the Tier-1 SDKs detect which one a client is speaking and respond in kind (MCP spec, Backward Compatibility).
Core method families
MCP method names follow a namespace/action convention, are case-sensitive, and use a forward slash as the separator: tools/list, tools/call, resources/read. The two families you'll see most are tools and resources.
Tools
Tools are functions the server exposes for a model to call. Discovery and invocation are two separate methods. tools/list returns the available tools, each with a JSON Schema inputSchema; tools/call runs one with arguments (MCP spec, Tools).
// tools/call request
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": { "name": "get_weather", "arguments": { "location": "New York" } }
}
// tools/call result
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"resultType": "complete",
"content": [{ "type": "text", "text": "72°F, partly cloudy" }],
"isError": false
}
}The result shape is the part people misremember. A tool result carries a content array of typed blocks (text, image, audio, resource_link, or embedded resource) and an optional isError flag (MCP spec, Tool Result). There's no toolResult field. A tool that returns machine-readable data can also include a structuredContent value validated against the tool's outputSchema (MCP spec, Structured Content).
MCP distinguishes two kinds of tool failure, and the distinction is deliberate. A malformed or unknown-tool request is a protocol error, returned as a JSON-RPC error object. A tool that ran but failed in its own logic, say an API timeout or an out-of-range input, is a tool execution error, returned as a normal result with isError: true (MCP spec, Tools error handling). The reason to keep them separate: execution errors go back to the model as content it can read and self-correct against, whereas protocol errors mean the request itself was broken and the model probably can't fix it.
Resources
Resources are data the server exposes for context: files, database rows, API responses, anything addressable by a URI. resources/list discovers them (with cursor-based pagination), and resources/read returns the contents of one (MCP spec, Resources).
// resources/read result
{
"jsonrpc": "2.0",
"id": 3,
"result": {
"resultType": "complete",
"contents": [
{ "uri": "file:///readme.md", "mimeType": "text/markdown", "text": "..." }
],
"ttlMs": 60000,
"cacheScope": "private"
}
}Resource contents come back as a contents array; each entry has a uri, an optional mimeType, and either text or a base64 blob for binary data (MCP spec, Resources). URI schemes signal what a resource is: the spec calls out https://, file://, and git://, and servers may define custom schemes (MCP spec, Common URI Schemes).
The ttlMs and cacheScope fields above aren't optional decoration. As of 2026-07-28 the list and read operations (tools/list, prompts/list, resources/list, resources/templates/list, resources/read, and server/discover) must return both, so clients know how long a response stays fresh and whether it can be reused outside the authorization context that fetched it (MCP spec, Caching).
Notification method names, and the ones that are not MCP
Notifications are where method names get miscopied most, often from the Language Server Protocol, which looks similar but uses a different vocabulary. These are the real MCP notification names.
notifications/tools/list_changed: server tells the client its tool list changed. The parallel forms exist for prompts and resources, and the resource form isnotifications/resources/list_changed(MCP spec, Tools, Resources).notifications/resources/updated: server tells a subscribed client that one specific resource changed, with theuriinparams. This is distinct fromlist_changed:updatedis per-resource,list_changedis "the set of resources changed" (MCP spec, Resources).notifications/progress: progress on a long request, keyed by aprogressTokenthe requester put in the request's_meta(MCP spec, Progress).notifications/cancelled: cancel an in-flight request, withrequestIdand an optionalreasoninparams. On stdio this is how a client cancels; on Streamable HTTP the client closes the response stream instead and no notification is sent (MCP spec, Cancellation).
The list-changed and resource-updated notifications are delivered on the response stream of a subscriptions/listen request the client opts into, which as of 2026-07-28 replaced the standalone GET stream and the resources/subscribe method (MCP spec, Subscriptions). One name is gone entirely: notifications/initialized, the client's "handshake done" message in earlier revisions, was removed along with initialize.
If you see $/cancelRequest or $/progress in an "MCP" example, those are LSP method names, not MCP. MCP's equivalents are notifications/cancelled and notifications/progress. The $/-prefixed forms won't do anything against an MCP peer.
Error codes
MCP uses JSON-RPC's standard error object: a code integer, a human-readable message, and optional data (MCP spec, Error Responses). The five standard JSON-RPC codes are defined in the JSON-RPC 2.0 spec and mean exactly what they've always meant. What changed in 2026-07-28 is the bottom row: MCP now splits the implementation-defined range in two and defines three codes of its own.
| Code | Meaning |
|---|---|
-32700 | Parse error: invalid JSON was received. |
-32600 | Invalid Request: the JSON sent is not a valid Request object. |
-32601 | Method not found: the method does not exist or is unavailable. |
-32602 | Invalid params: invalid method parameters. |
-32603 | Internal error: an internal JSON-RPC error. |
-32000 to -32019 | Legacy: codes implementations allocated before the policy existed. No new allocations here, and apart from -32002 you must not assume any meaning. |
-32020 to -32099 | Reserved for the MCP specification. Only spec-defined codes may be emitted from this sub-range. |
The legacy band is where the SDK-defined codes people search for live: -32000 (connection closed) and -32001 (request timeout) were allocated by SDKs, never by the spec, and they're grandfathered rather than standardized (MCP spec, Error Codes). New implementations shouldn't add to that band, and new non-spec codes should be allocated outside JSON-RPC's reserved -32768 to -32000 range entirely.
The spec-reserved band currently holds three codes, all of them about the per-request metadata described earlier:
| Code | Name | Raised when |
|---|---|---|
-32020 | HeaderMismatch | An HTTP header disagrees with the matching _meta value in the body. |
-32021 | MissingRequiredClientCapability | The request needs a capability the client didn't declare; data.requiredCapabilities lists them. |
-32022 | UnsupportedProtocolVersion | The server doesn't implement the requested version; data.supported lists the ones it does. |
Two codes were retired and must not be emitted by current-revision implementations. -32002 meant resource-not-found on resources/read through 2025-11-25; that case is now plain -32602 (Invalid params), though clients should keep accepting -32002 from servers on older revisions (MCP spec, Resources error handling). -32042, which existed only in 2025-11-25 to signal that URL elicitation was required, is gone with the mechanism that used it. Treat any other confidently-cited "MCP error code" you can't find in the spec as suspect.
{
"jsonrpc": "2.0",
"id": 4,
"error": {
"code": -32602,
"message": "Invalid params",
"data": { "field": "location", "reason": "required" }
}
}For the two most-searched codes, -32601 and the serialization-layer codes -32700/-32600, there are dedicated troubleshooting guides linked below.
How the transport carries these messages
JSON-RPC defines the message shapes; a transport defines how the bytes move. MCP defines exactly two current transports: stdio, where the client launches the server as a subprocess and exchanges newline-delimited JSON over stdin/stdout, and Streamable HTTP, where the server runs as a network service behind a single POST endpoint (MCP spec, Transports). Whichever you use, the messages inside are the same JSON-RPC objects described here. On HTTP, some of the _meta fields are mirrored into headers, MCP-Protocol-Version alongside the required Mcp-Method and Mcp-Name, and a header that disagrees with the body earns a 400 and -32020 (MCP spec, Streamable HTTP).
One stdio rule follows directly from the message format and causes a lot of "connection closed" reports: messages are delimited by newlines and cannot contain embedded newlines, and the server must not write anything to stdout that is not a valid MCP message (MCP spec, stdio). A stray print or console.log to stdout injects non-JSON into the stream and corrupts the next message. Logging belongs on stderr. The message serialization guide covers this failure mode in depth. For choosing between the two transports, see comparing stdio, SSE, and Streamable HTTP.
Reading it on the wire
Because every message is plain JSON-RPC, you can debug MCP by watching the traffic. The MCP Inspector runs a server and shows the request and response for every method call, which is the fastest way to confirm a server answers at all, a method name is spelled right, and a result has the shape your client expects. See setting up the MCP Inspector. The official SDKs build all of these messages for you, so in normal use you write handlers rather than hand-assembling JSON. Which package you install decides which revision you get: the TypeScript monolith @modelcontextprotocol/sdk is now the legacy line at 1.30.0, while the 2026-07-28 revision ships in the split 2.0.0 packages (@modelcontextprotocol/server, @modelcontextprotocol/client, @modelcontextprotocol/core, plus HTTP adapters) (TypeScript SDK). Knowing the underlying shapes is what lets you tell an SDK bug from a protocol mistake when something goes wrong.
Where to go from here
If you do hit a specific error, fixing "Method not found (-32601)" and debugging message serialization errors are the practical follow-ups, and both build on the shapes above. For the full protocol, the MCP specification is the source of truth, with its TypeScript schema defining every message exactly.
Once a server is answering real clients, the questions shift from "is the message well-formed" to "which tools are clients actually calling, with what arguments, and where do calls error or stall." That production visibility is the gap AgentCat fills for MCP servers.
Related Guides
Debugging message serialization errors in MCP protocol
Debug and fix MCP message serialization errors with proven troubleshooting techniques.
Fixing "Method not found (-32601)" JSON-RPC errors
Troubleshoot JSON-RPC method not found errors in MCP servers with detailed debugging strategies.
Comparing stdio vs. SSE vs. Streamable HTTP
How to choose an MCP transport: stdio for local subprocesses, Streamable HTTP for remote services, and why the old HTTP+SSE transport is deprecated, not a third option.